severally-mcp 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +146 -69
  4. package/README.md +150 -73
  5. package/bin/severally-mcp.mjs +17 -2
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +59 -24
  10. package/docs/install.md +60 -24
  11. package/package.json +16 -4
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29220 -27764
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/build.mjs +31 -26
  26. package/scripts/doctor.mjs +52 -17
  27. package/scripts/init-config.mjs +8 -6
  28. package/scripts/install.mjs +342 -84
  29. package/scripts/live-check.mjs +52 -19
  30. package/scripts/live-mcp-check.mjs +42 -12
  31. package/scripts/test.mjs +10 -4
  32. package/src/adapters/antigravity-sandbox.mjs +39 -23
  33. package/src/adapters/antigravity.mjs +34 -16
  34. package/src/adapters/claude-code.mjs +73 -21
  35. package/src/adapters/codex.mjs +69 -27
  36. package/src/adapters/opencode-sandbox.mjs +40 -21
  37. package/src/adapters/opencode.mjs +31 -13
  38. package/src/brief.mjs +65 -41
  39. package/src/consultants.mjs +99 -30
  40. package/src/export.mjs +49 -12
  41. package/src/expose-paths.mjs +162 -44
  42. package/src/failures.mjs +23 -13
  43. package/src/jobs.mjs +420 -140
  44. package/src/parse-result.mjs +85 -30
  45. package/src/platform.mjs +5 -7
  46. package/src/policy.mjs +139 -32
  47. package/src/redact.mjs +38 -14
  48. package/src/run.mjs +104 -22
  49. package/src/schema.mjs +76 -22
  50. package/src/server.mjs +176 -166
  51. package/src/status.mjs +132 -0
  52. package/src/store.mjs +164 -19
package/src/jobs.mjs CHANGED
@@ -4,7 +4,16 @@
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, consultantAvailable } from './policy.mjs';
7
+ import {
8
+ POLICY,
9
+ VERDICTS,
10
+ limitsSummary,
11
+ timeoutMs,
12
+ detectCaller,
13
+ configProblem,
14
+ consultantByName,
15
+ consultantAvailable,
16
+ } from './policy.mjs';
8
17
  import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
9
18
  import { parseRequest, RequestError, charCount } from './schema.mjs';
10
19
  import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
@@ -27,6 +36,9 @@ const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
27
36
  // anyway would silently extend the job's advertised wall-clock deadline.
28
37
  const MIN_RETRY_MS = 5_000;
29
38
 
39
+ // How often a server looks for a cancel another server asked for.
40
+ const CANCEL_POLL_MS = 1_000;
41
+
30
42
  // The second way a consultant can be authenticated. A linked token file and an
31
43
  // API key / ADC file are alternatives, not both required, so the presence of
32
44
  // either has to satisfy the credential check below. These are the names
@@ -68,13 +80,20 @@ function ownerState(jobId, owner) {
68
80
  if (!owner) return 'dead';
69
81
  let pidAlive;
70
82
  if (owner.pid === process.pid) pidAlive = LIVE_HERE.has(jobId);
71
- else try { process.kill(owner.pid, 0); pidAlive = true; } catch (err) { pidAlive = err.code === 'EPERM'; }
83
+ else
84
+ try {
85
+ process.kill(owner.pid, 0);
86
+ pidAlive = true;
87
+ } catch (err) {
88
+ pidAlive = err.code === 'EPERM';
89
+ }
72
90
  if (!pidAlive) return 'dead';
73
91
  return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
74
92
  }
75
93
 
76
94
  export class JobManager {
77
95
  constructor() {
96
+ this._cancelWatch = null;
78
97
  this.jobs = new Map();
79
98
  this.groups = new Map();
80
99
  this.order = [];
@@ -100,7 +119,10 @@ export class JobManager {
100
119
  // owner finished and died before removing the marker.
101
120
  #interrupt(jobId, rec, state) {
102
121
  const done = store.loadRound(jobId);
103
- if (done) { store.removeRunning(jobId); return done; }
122
+ if (done) {
123
+ store.removeRunning(jobId);
124
+ return done;
125
+ }
104
126
  const { owner, ...round } = rec;
105
127
  round.status = 'failed';
106
128
  round.finished_at = new Date().toISOString();
@@ -111,7 +133,11 @@ export class JobManager {
111
133
  retriable: true,
112
134
  delivered_advice: false,
113
135
  };
114
- try { store.persistRound(round); } catch { return round; }
136
+ try {
137
+ store.persistRound(round);
138
+ } catch {
139
+ return round;
140
+ }
115
141
  store.removeRunning(jobId);
116
142
  // An overdue owner may still be running its consultant: leave its working
117
143
  // directory to it. ponytail: if the pid was reused instead, that
@@ -192,6 +218,7 @@ export class JobManager {
192
218
 
193
219
  // `detail: "full"` returns the brief as stored; by default a finished
194
220
  // consultation names it (size, sha256, round file) instead of repeating it.
221
+ /** @param {string} jobId @param {{detail?: string}} [opts] */
195
222
  view(jobId, { detail } = {}) {
196
223
  const job = this.jobs.get(jobId);
197
224
  if (!job) {
@@ -203,9 +230,10 @@ export class JobManager {
203
230
  // While it runs, say what it is doing. "running" for twenty minutes tells
204
231
  // the lead nothing; a consultation visibly grinding through tool calls is
205
232
  // one it can cancel now rather than wait out.
206
- rec.progress = job.status === 'running' && job._handle
207
- ? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
208
- : null;
233
+ rec.progress =
234
+ job.status === 'running' && job._handle
235
+ ? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
236
+ : null;
209
237
  rec.record = job.record ? recordSummary(job) : null;
210
238
  rec.prediction = job.prediction ?? null;
211
239
  rec.reflection = job.reflection ?? null;
@@ -219,39 +247,71 @@ export class JobManager {
219
247
  const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
220
248
  if (live) {
221
249
  rec.brief = null;
222
- rec.brief_note = 'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
250
+ rec.brief_note =
251
+ 'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
223
252
  }
224
253
  rec.limits = live ? null : limitsSummary();
225
254
  rec.next_step = nextStep(job);
226
255
  return compactBrief(rec, detail);
227
256
  }
228
257
 
258
+ // This session's consultations and the history's, so an earlier session's
259
+ // job_id and group_id can still be followed up and recorded against. The
260
+ // index only picks and orders; each row shown is read from its round, which
261
+ // holds the verdicts written since. Memory wins over disk for the same job.
229
262
  list({ limit = 20 } = {}) {
230
- return this.order
231
- .slice(-limit)
232
- .reverse()
233
- .map((jid) => {
234
- const j = this.jobs.get(jid);
235
- return {
236
- job_id: j.job_id,
237
- chain_id: j.chain_id,
238
- round: j.round,
239
- target: j.target,
240
- cli: j.cli,
241
- model: j.model,
242
- mode: j.mode,
243
- status: j.status,
244
- failure_kind: j.failure?.kind ?? null,
245
- created_at: j.created_at,
246
- duration_ms: j.duration_ms,
247
- summary: j.result ? j.result.summary.slice(0, 200) : null,
248
- recorded: Boolean(j.record),
249
- verdicts: j.record ? tally(j.record.entries.map((e) => e.verdict)) : null,
250
- predicted: Boolean(j.prediction),
251
- reflected: Boolean(j.reflection),
252
- initiator: j.initiator ?? null,
253
- };
263
+ const here = this.order.map((jid) => this.jobs.get(jid));
264
+ const seen = new Set(here.map((j) => j.job_id));
265
+ const elsewhere = [];
266
+ // Read only: a marker whose owner is gone is shown as interrupted, and
267
+ // left for consult_get or the startup sweep to close out.
268
+ for (const jobId of store.listRunning()) {
269
+ const live = seen.has(jobId) ? null : store.loadRunning(jobId);
270
+ if (!live) continue;
271
+ seen.add(jobId);
272
+ const state = ownerState(jobId, live.owner);
273
+ // A round saved just before its owner died is the answer, whatever the marker says.
274
+ const saved = state === 'alive' ? null : store.loadRoundAt(live);
275
+ elsewhere.push({
276
+ rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
254
277
  });
278
+ }
279
+ for (const row of store.indexRows())
280
+ if (!seen.has(row.job_id)) {
281
+ seen.add(row.job_id);
282
+ elsewhere.push({ row });
283
+ }
284
+ const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
285
+ const picked = [];
286
+ // Round files are read only for the rows shown; one that cannot be read is passed over.
287
+ for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
288
+ if (picked.length >= limit) break;
289
+ const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
290
+ if (j) picked.push(j);
291
+ }
292
+ return picked.map((j) => {
293
+ return {
294
+ job_id: j.job_id,
295
+ group_id: j.group_id ?? null,
296
+ chain_id: j.chain_id,
297
+ round: j.round,
298
+ target: j.target,
299
+ cli: j.cli,
300
+ model: j.model,
301
+ mode: j.mode,
302
+ status: j.status,
303
+ failure_kind: j.failure?.kind ?? null,
304
+ created_at: j.created_at,
305
+ duration_ms: j.duration_ms,
306
+ summary: j.result ? j.result.summary.slice(0, 200) : null,
307
+ recorded: Boolean(j.record),
308
+ verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
309
+ this_session: this.jobs.has(j.job_id),
310
+ predicted: Boolean(j.prediction),
311
+ reflected: Boolean(j.reflection),
312
+ initiator: j.initiator ?? null,
313
+ };
314
+ });
255
315
  }
256
316
 
257
317
  /**
@@ -271,7 +331,11 @@ export class JobManager {
271
331
  t.consultations += 1;
272
332
  t.attempts += attempts.length;
273
333
  for (const u of attempts) {
274
- for (const [k, v] of Object.entries(u)) if (typeof v === 'number') { t[k] = (t[k] ?? 0) + v; seen.add(k); }
334
+ for (const [k, v] of Object.entries(u))
335
+ if (typeof v === 'number') {
336
+ t[k] = (t[k] ?? 0) + v;
337
+ seen.add(k);
338
+ }
275
339
  }
276
340
  }
277
341
  for (const [target, t] of Object.entries(by)) {
@@ -289,12 +353,12 @@ export class JobManager {
289
353
  }
290
354
  // A config the operator got wrong is not run on defaults: that could
291
355
  // re-enable a CLI they turned off. Reading the history still works.
292
- if (configProblem()) throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
356
+ if (configProblem())
357
+ throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
293
358
 
294
359
  // Resolve the chain before validating, so follow-up rules can be applied.
295
- const followupTo = typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim()
296
- ? rawRequest.followup_to.trim()
297
- : null;
360
+ const followupTo =
361
+ typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
298
362
  let parent = null;
299
363
  let fromGroup = null;
300
364
  if (followupTo?.startsWith('group_')) {
@@ -334,11 +398,20 @@ export class JobManager {
334
398
  parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
335
399
  }
336
400
 
401
+ if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
402
+ rawRequest = { ...rawRequest, target: parent.target };
403
+ }
404
+ // A fan-out's members were all handed the same brief, so any one of them holds it.
405
+ if (parent || fromGroup)
406
+ rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
407
+
337
408
  const req = parseRequest(rawRequest, {
338
409
  isFollowup: Boolean(parent || fromGroup),
339
410
  parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
340
411
  groupFollowup: Boolean(fromGroup),
341
412
  });
413
+ // The operator's setting only ever closes: a request cannot reopen the web.
414
+ if (POLICY.network === 'off') req.network = 'off';
342
415
 
343
416
  if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
344
417
  throw new RequestError(
@@ -363,21 +436,36 @@ export class JobManager {
363
436
  // Admit or refuse the whole fan-out: a half-started group would leave the
364
437
  // lead comparing answers to a question only some consultants were asked.
365
438
  // The cap counts every severally process on this home, through the seats.
366
- const seats = req.targets.map((target) => ({
439
+ // A follow-up's seat also names the chain and round it opens, so that two
440
+ // servers continuing one consultation at once cannot both take a round
441
+ // number: neither sees the other's round in its history until it ends.
442
+ const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
443
+ const seats = req.targets.map((target, i) => ({
367
444
  job_id: id('job'),
445
+ ...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
368
446
  // Twice the budget plus an hour: the deadline is wall-clock, the budget is
369
447
  // not, so a suspended laptop must not make a live owner look overdue.
370
448
  owner: {
371
449
  pid: process.pid,
372
- deadline_at: new Date(Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000).toISOString(),
450
+ deadline_at: new Date(
451
+ Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
452
+ ).toISOString(),
373
453
  },
374
454
  }));
375
455
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
456
+ if (taken.chainBusy) {
457
+ const { live, mine } = taken.chainBusy;
458
+ throw new RequestError(
459
+ `another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}` +
460
+ '; wait for it (consult_get) and follow up the latest round',
461
+ 'followup_not_latest',
462
+ );
463
+ }
376
464
  if (!taken.ok) {
377
465
  throw new RequestError(
378
- `this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
379
- + (taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '')
380
- + '. Wait for one to finish (consult_get) or cancel it',
466
+ `this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}` +
467
+ (taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '') +
468
+ '. Wait for one to finish (consult_get) or cancel it',
381
469
  'concurrency_limit',
382
470
  );
383
471
  }
@@ -404,7 +492,11 @@ export class JobManager {
404
492
  for (const jid of group.job_ids) {
405
493
  const member = this.jobs.get(jid) ?? store.loadRound(jid);
406
494
  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' });
495
+ skipped.push({
496
+ job_id: jid,
497
+ target: null,
498
+ reason: 'not found: aged out of the history, or still running in another severally process',
499
+ });
408
500
  continue;
409
501
  }
410
502
  const rounds = this.#chainRounds(member.chain_id);
@@ -413,10 +505,14 @@ export class JobManager {
413
505
  // The name is kept, not re-resolved: a consultant renamed or turned off
414
506
  // since is skipped, never continued by whichever one answers to it now.
415
507
  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'
508
+ const reason = !latest
509
+ ? `${member.status}, so there is no answer to follow up`
510
+ : latest.status !== 'completed'
511
+ ? `round ${latest.round} is still ${latest.status}`
512
+ : nextRound > POLICY.maxRounds
513
+ ? `has used its ${POLICY.maxRounds} rounds`
514
+ : !consultant || !consultantAvailable(consultant)
515
+ ? 'this consultant is no longer configured or available here'
420
516
  : null;
421
517
  if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
422
518
  else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
@@ -441,7 +537,7 @@ export class JobManager {
441
537
  // missing or a tree that blows the cap is the lead's to fix now rather
442
538
  // than a job that fails having already spent a round of this chain.
443
539
  let exposeManifest = null;
444
- const parentEntries = req.context.expose_from_parent ? parent.brief?.expose_paths?.entries ?? [] : [];
540
+ const parentEntries = req.context.expose_from_parent ? (parent.brief?.expose_paths?.entries ?? []) : [];
445
541
  if (req.context.expose_from_parent && parentEntries.length === 0) {
446
542
  throw new RequestError(
447
543
  `context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
@@ -453,8 +549,11 @@ export class JobManager {
453
549
  try {
454
550
  fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
455
551
  const plan = planInheritance(parentEntries, req.context.expose_paths);
456
- exposeManifest = materializeExposedPaths(snapshot, plan.paths)
457
- ?? { entries: [], totals: { files: 0, bytes: 0 }, skipped: [] };
552
+ exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
553
+ entries: [],
554
+ totals: { files: 0, bytes: 0 },
555
+ skipped: [],
556
+ };
458
557
  exposeManifest.skipped.push(...plan.skipped);
459
558
  const parentSources = new Set(parentEntries.map((e) => e.source));
460
559
  for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
@@ -472,14 +571,21 @@ export class JobManager {
472
571
  const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
473
572
  const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
474
573
  if (diff) {
475
- req.context.artifacts = [...req.context.artifacts,
476
- { name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff }];
574
+ req.context.artifacts = [
575
+ ...req.context.artifacts,
576
+ { name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff },
577
+ ];
477
578
  }
478
579
  }
479
580
  markSinceParent(exposeManifest, parentEntries);
480
581
  // Every earlier path gone, and nothing else to go on: refused, as a
481
582
  // first round with no context is.
482
- if (parentEntries.length && !exposeManifest.entries.length && !req.context.facts.length && !req.context.artifacts.length) {
583
+ if (
584
+ parentEntries.length &&
585
+ !exposeManifest.entries.length &&
586
+ !req.context.facts.length &&
587
+ !req.context.artifacts.length
588
+ ) {
483
589
  throw new RequestError(
484
590
  `context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
485
591
  'nothing_to_inherit',
@@ -550,6 +656,7 @@ export class JobManager {
550
656
  this.jobs.set(jobId, job);
551
657
  this.order.push(jobId);
552
658
  LIVE_HERE.add(jobId);
659
+ this.#watchCancels();
553
660
  job._owner = seats[i].owner;
554
661
  this.#mark(job);
555
662
  return job;
@@ -564,7 +671,11 @@ export class JobManager {
564
671
  job_ids: members.map((m) => m.job_id),
565
672
  };
566
673
  this.groups.set(groupId, group);
567
- try { store.persistGroup(group); } catch { /* history is best effort */ }
674
+ try {
675
+ store.persistGroup(group);
676
+ } catch {
677
+ /* history is best effort */
678
+ }
568
679
 
569
680
  // A fan-out is only comparable if every consultant answered the same
570
681
  // question in the same words: every member's brief is the same request,
@@ -595,9 +706,12 @@ export class JobManager {
595
706
  // consultation, but one about code that hands over no file is one the
596
707
  // consultant answers without having seen the repository.
597
708
  files_exposed: exposeManifest?.totals.files ?? 0,
598
- ...(exposeManifest?.totals.files ? {} : {
599
- files_note: 'no file was handed over, so the consultant answers from the brief alone; if this is about code in a repository, consult_cancel and start again with context.expose_paths',
600
- }),
709
+ ...(exposeManifest?.totals.files
710
+ ? {}
711
+ : {
712
+ files_note:
713
+ 'no file was handed over, so the consultant answers from the brief alone; if this is about code in a repository, consult_cancel and start again with context.expose_paths',
714
+ }),
601
715
  status: 'running',
602
716
  accepted_at: group.created_at,
603
717
  limits: limitsSummary(),
@@ -619,7 +733,11 @@ export class JobManager {
619
733
  ...common,
620
734
  question: group.question, // the redacted text every member was actually sent
621
735
  jobs: members.map((m) => ({
622
- job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model,
736
+ job_id: m.job_id,
737
+ chain_id: m.chain_id,
738
+ target: m.target,
739
+ cli: m.cli,
740
+ model: m.model,
623
741
  ...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
624
742
  })),
625
743
  poll_with: `consult_get({ group_id: "${groupId}" })`,
@@ -645,10 +763,13 @@ export class JobManager {
645
763
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
646
764
  // The checked points first, then the unchecked ones, each with the
647
765
  // 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 }));
766
+ const ordered = (items, shape) =>
767
+ [...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
768
+ id: x.id,
769
+ ...shape(x),
770
+ verdict: verdicts.get(x.id)?.verdict ?? null,
771
+ effect: verdicts.get(x.id)?.effect ?? null,
772
+ }));
652
773
  const checked = (x) => x.verdict !== null;
653
774
  const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
654
775
  return {
@@ -746,11 +867,11 @@ export class JobManager {
746
867
  return this.#fail(
747
868
  job,
748
869
  'auth',
749
- `no ${t.label} credential to hand the consultant: nothing at `
750
- + `${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a `
751
- + 'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home '
752
- + 'directory that holds the token, or set one of those variables to authenticate with an API key '
753
- + 'instead.',
870
+ `no ${t.label} credential to hand the consultant: nothing at ` +
871
+ `${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a ` +
872
+ 'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home ' +
873
+ 'directory that holds the token, or set one of those variables to authenticate with an API key ' +
874
+ 'instead.',
754
875
  );
755
876
  }
756
877
 
@@ -782,8 +903,9 @@ export class JobManager {
782
903
  // What this consultant was actually launched with, read off the
783
904
  // invocation and the sandbox rather than restated from intent, so the
784
905
  // record says what held for this run.
785
- job.boundary = adapter.boundary?.({ args: invocation.args, sandbox, network: req.network,
786
- hasExposedPaths: Boolean(req._snapshot) }) ?? null;
906
+ job.boundary =
907
+ adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
908
+ null;
787
909
 
788
910
  const attempt = (timeout) => {
789
911
  const { handle, done } = runChild({
@@ -825,7 +947,9 @@ export class JobManager {
825
947
  if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
826
948
  try {
827
949
  job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
828
- } catch { /* history is best effort */ }
950
+ } catch {
951
+ /* history is best effort */
952
+ }
829
953
  }
830
954
  if (run.spawnError) {
831
955
  return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
@@ -860,9 +984,12 @@ export class JobManager {
860
984
  // No envelope at all: what stderr holds is the dump from stopping it,
861
985
  // not a message to classify. Not re-run, as it may have spent its quota.
862
986
  if (run.stalled && !interpreted.usageRaw) {
863
- return this.#fail(job, 'cli_error',
987
+ return this.#fail(
988
+ job,
989
+ 'cli_error',
864
990
  `${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
865
- adapter.progress?.(run) ?? null);
991
+ adapter.progress?.(run) ?? null,
992
+ );
866
993
  }
867
994
 
868
995
  const remaining = budgetMs - (Date.now() - t0);
@@ -870,19 +997,35 @@ export class JobManager {
870
997
  authTried = true;
871
998
  const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
872
999
  if (seeded && remaining >= MIN_RETRY_MS) {
873
- job.retries.push({ failure_kind: interpreted.failureKind, reason: 'credentials re-seeded', at: new Date().toISOString(), usage: adapter.usageRecord(interpreted.usageRaw) });
1000
+ job.retries.push({
1001
+ failure_kind: interpreted.failureKind,
1002
+ reason: 'credentials re-seeded',
1003
+ at: new Date().toISOString(),
1004
+ usage: adapter.usageRecord(interpreted.usageRaw),
1005
+ });
874
1006
  run = await attempt(remaining);
875
1007
  continue;
876
1008
  }
877
1009
  }
878
1010
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
879
- if (AUTO_RETRY_KINDS.includes(interpreted.failureKind) && autoRetries < POLICY.maxAutoRetries
880
- && remaining - wait >= MIN_RETRY_MS) {
1011
+ if (
1012
+ AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
1013
+ autoRetries < POLICY.maxAutoRetries &&
1014
+ remaining - wait >= MIN_RETRY_MS
1015
+ ) {
881
1016
  autoRetries += 1;
882
- job.retries.push({ failure_kind: interpreted.failureKind, reason: `transient; re-run after ${wait} ms`, at: new Date().toISOString(), usage: adapter.usageRecord(interpreted.usageRaw) });
1017
+ job.retries.push({
1018
+ failure_kind: interpreted.failureKind,
1019
+ reason: `transient; re-run after ${wait} ms`,
1020
+ at: new Date().toISOString(),
1021
+ usage: adapter.usageRecord(interpreted.usageRaw),
1022
+ });
883
1023
  await new Promise((resolve) => {
884
1024
  const timer = setTimeout(resolve, wait);
885
- job._cancelFns.push(() => { clearTimeout(timer); resolve(); });
1025
+ job._cancelFns.push(() => {
1026
+ clearTimeout(timer);
1027
+ resolve();
1028
+ });
886
1029
  });
887
1030
  if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
888
1031
  run = await attempt(budgetMs - (Date.now() - t0));
@@ -891,8 +1034,12 @@ export class JobManager {
891
1034
  break;
892
1035
  }
893
1036
 
1037
+ // Without an answer, a process left holding the pipes is the failure to report.
1038
+ const leftover =
1039
+ run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
894
1040
  if (!interpreted.ok) {
895
1041
  job.usage = adapter.usageRecord(interpreted.usageRaw);
1042
+ if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
896
1043
  return this.#fail(job, interpreted.failureKind, interpreted.message);
897
1044
  }
898
1045
 
@@ -916,6 +1063,7 @@ export class JobManager {
916
1063
  }
917
1064
  if (!normalized) {
918
1065
  job.usage = adapter.usageRecord(interpreted.usageRaw);
1066
+ if (leftover) return this.#fail(job, 'cli_error', leftover, err.message);
919
1067
  return this.#fail(job, 'invalid_output', err.message, redact(String(err.detail ?? '')).slice(0, 1200));
920
1068
  }
921
1069
  } else {
@@ -927,7 +1075,10 @@ export class JobManager {
927
1075
  const notes = [
928
1076
  adviceCaveat(normalized),
929
1077
  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)}`,
1078
+ interpreted.recoveredError &&
1079
+ `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
1080
+ leftover &&
1081
+ 'the answer parsed, but a process the consultant started kept its output open after it exited and was stopped (one that left its process group may still be running)',
931
1082
  ].filter(Boolean);
932
1083
  // No `advice_usable` verdict here. It was always true, so it carried no
933
1084
  // information -- and worse, it read as the server vouching for advice it
@@ -967,11 +1118,19 @@ export class JobManager {
967
1118
 
968
1119
  // The in-progress marker other processes read (see store.writeRunning).
969
1120
  #mark(job) {
970
- try { store.writeRunning({ ...this.#record(job), owner: job._owner }); } catch { /* history is best effort */ }
1121
+ try {
1122
+ store.writeRunning({ ...this.#record(job), owner: job._owner });
1123
+ } catch {
1124
+ /* history is best effort */
1125
+ }
971
1126
  }
972
1127
 
973
1128
  #finish(job) {
974
- try { store.persistRound(this.#record(job)); } catch { /* history is best effort */ }
1129
+ try {
1130
+ store.persistRound(this.#record(job));
1131
+ } catch {
1132
+ /* history is best effort */
1133
+ }
975
1134
  store.removeRunning(job.job_id);
976
1135
  store.releaseSeat(job.job_id);
977
1136
  LIVE_HERE.delete(job.job_id);
@@ -986,16 +1145,17 @@ export class JobManager {
986
1145
  // consultant did not produce and a word outside VERDICTS, then stores what
987
1146
  // was written. It never decides that a finding was adopted, and it never
988
1147
  // fills in a verdict the lead did not write.
1148
+ /** @param {Record<string, any>} [input] */
989
1149
  record(input = {}) {
990
1150
  if (input.records !== undefined) return this.#recordMany(input);
991
1151
  const allowed = ['job_id', 'entries', 'reflection'];
992
1152
  const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
993
1153
  if (unknown.length) {
994
1154
  throw new RequestError(
995
- `${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records`
996
- + (unknown.includes('prediction')
997
- ? '. A prediction is written when the consultation starts (consult_start\'s `prediction`) and cannot be added or changed afterwards -- that is what makes it a prediction'
998
- : ''),
1155
+ `${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records` +
1156
+ (unknown.includes('prediction')
1157
+ ? ". A prediction is written when the consultation starts (consult_start's `prediction`) and cannot be added or changed afterwards -- that is what makes it a prediction"
1158
+ : ''),
999
1159
  'unknown_field',
1000
1160
  );
1001
1161
  }
@@ -1012,7 +1172,10 @@ export class JobManager {
1012
1172
  #recordMany(input) {
1013
1173
  const extra = Object.keys(input).filter((k) => k !== 'records');
1014
1174
  if (extra.length) {
1015
- throw new RequestError(`records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`, 'unknown_field');
1175
+ throw new RequestError(
1176
+ `records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`,
1177
+ 'unknown_field',
1178
+ );
1016
1179
  }
1017
1180
  const { records } = input;
1018
1181
  if (!Array.isArray(records) || records.length === 0) {
@@ -1023,10 +1186,12 @@ export class JobManager {
1023
1186
  const seen = new Set();
1024
1187
  records.forEach((r, i) => {
1025
1188
  try {
1026
- if (seen.has(r?.job_id)) throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
1189
+ if (seen.has(r?.job_id))
1190
+ throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
1027
1191
  seen.add(r?.job_id);
1028
1192
  const unknown = Object.keys(r ?? {}).filter((k) => !['job_id', 'entries', 'reflection'].includes(k));
1029
- if (unknown.length) throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
1193
+ if (unknown.length)
1194
+ throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
1030
1195
  changes.push(this.#checkRecord(r ?? {}));
1031
1196
  } catch (err) {
1032
1197
  if (!(err instanceof RequestError)) throw err;
@@ -1047,9 +1212,13 @@ export class JobManager {
1047
1212
 
1048
1213
  // Everything that can be wrong with a record, found before anything is
1049
1214
  // written: what the job's record and reflection become, or a RequestError.
1215
+ /** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
1050
1216
  #checkRecord({ job_id: jobId, entries, reflection }) {
1051
1217
  if (entries === undefined && reflection === undefined) {
1052
- throw new RequestError('pass entries (verdicts on specific points), reflection (what the answer added), or both', 'nothing_to_record');
1218
+ throw new RequestError(
1219
+ 'pass entries (verdicts on specific points), reflection (what the answer added), or both',
1220
+ 'nothing_to_record',
1221
+ );
1053
1222
  }
1054
1223
  // A job this process ran is in memory; one from an earlier session is read
1055
1224
  // back from its round file, so a verdict can still be written once the
@@ -1057,10 +1226,7 @@ export class JobManager {
1057
1226
  const live = this.jobs.get(jobId);
1058
1227
  const job = live ?? store.loadRound(jobId);
1059
1228
  if (!job) {
1060
- throw new RequestError(
1061
- `no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
1062
- 'unknown_job',
1063
- );
1229
+ throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
1064
1230
  }
1065
1231
  if (!job.result) {
1066
1232
  throw new RequestError(
@@ -1123,9 +1289,10 @@ export class JobManager {
1123
1289
  newReflection = { delta, related_item_ids: related, recorded_at: now };
1124
1290
  }
1125
1291
 
1126
- const newRecord = entries === undefined
1127
- ? undefined
1128
- : { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
1292
+ const newRecord =
1293
+ entries === undefined
1294
+ ? undefined
1295
+ : { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
1129
1296
  return { job, live, record: newRecord, reflection: newReflection };
1130
1297
  }
1131
1298
 
@@ -1183,16 +1350,32 @@ export class JobManager {
1183
1350
  return view;
1184
1351
  }
1185
1352
 
1353
+ // Cancels another server asked for (store.requestCancel), checked while
1354
+ // this one runs anything. ponytail: polled once a second, not signalled;
1355
+ // a watch on the slots directory if that delay ever matters.
1356
+ #watchCancels() {
1357
+ this._cancelWatch ??= setInterval(() => {
1358
+ for (const job of this.jobs.values()) {
1359
+ if ((job.status === 'running' || job.status === 'queued') && store.cancelRequested(job.job_id)) {
1360
+ store.clearCancel(job.job_id);
1361
+ this.cancel(job.job_id);
1362
+ }
1363
+ }
1364
+ }, CANCEL_POLL_MS).unref();
1365
+ }
1366
+
1186
1367
  cancel(jobId) {
1187
1368
  const job = this.jobs.get(jobId);
1188
1369
  if (!job) {
1189
1370
  const view = this.view(jobId);
1190
1371
  if (!view) return null;
1372
+ if (view.status !== 'running' && view.status !== 'queued')
1373
+ return { ...view, cancel_effect: `job was already ${view.status}` };
1191
1374
  return {
1192
1375
  ...view,
1193
- cancel_effect: view.status === 'running' || view.status === 'queued'
1194
- ? 'nothing signalled: this consultation runs in another severally process, and only that process can cancel it'
1195
- : `job was already ${view.status}`,
1376
+ cancel_effect: store.requestCancel(jobId)
1377
+ ? `asked the severally process running it to stop it; that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
1378
+ : 'nothing signalled: the request to the severally process running it could not be written',
1196
1379
  };
1197
1380
  }
1198
1381
  if (job.status !== 'running' && job.status !== 'queued') {
@@ -1207,6 +1390,7 @@ export class JobManager {
1207
1390
  // Every field here is derived from the one `members` array it is handed, so a
1208
1391
  // group view can never report one status in `members` and another in
1209
1392
  // `comparison` or `status` for the same job.
1393
+ /** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
1210
1394
  #shapeGroup(group, members, { detail } = {}) {
1211
1395
  const full = detail === 'full';
1212
1396
  const cancelling = members.some((m) => m.status === 'cancelling');
@@ -1223,9 +1407,10 @@ export class JobManager {
1223
1407
  // same thing three times. Hoist one copy to the group and strip it from
1224
1408
  // the members, which keeps the payload readable without losing anything.
1225
1409
  const firstBrief = members.find((m) => m.brief)?.brief ?? null;
1226
- const groupBrief = firstBrief && !full
1227
- ? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
1228
- : firstBrief;
1410
+ const groupBrief =
1411
+ firstBrief && !full
1412
+ ? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
1413
+ : firstBrief;
1229
1414
  // The limits are the server's, not a member's: one copy, on the group.
1230
1415
  // By default a member is only its state: what there is to compare is in
1231
1416
  // `comparison`, once, and the full answer is one consult_get({ job_id }) away.
@@ -1233,7 +1418,8 @@ export class JobManager {
1233
1418
  if (full) return rest;
1234
1419
  const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
1235
1420
  const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
1236
- const cutShort = progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
1421
+ const cutShort =
1422
+ progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
1237
1423
  return {
1238
1424
  job_id: rest.job_id,
1239
1425
  target: rest.target,
@@ -1249,19 +1435,22 @@ export class JobManager {
1249
1435
  const question = full ? group.question : cut(group.question, QUESTION_CHARS);
1250
1436
  const view = {
1251
1437
  group_id: group.group_id,
1252
- status: cancelling ? 'cancelling' : (live ? 'running' : 'done'),
1438
+ status: cancelling ? 'cancelling' : live ? 'running' : 'done',
1253
1439
  mode: group.mode,
1254
1440
  question,
1255
- ...(question !== group.question ? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` } : {}),
1441
+ ...(question !== group.question
1442
+ ? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` }
1443
+ : {}),
1256
1444
  brief: groupBrief,
1257
1445
  members_expected: group.job_ids.length,
1258
1446
  members_available: members.length,
1259
1447
  // History is capped, so an older member can already be gone. Say so:
1260
1448
  // a fan-out that quietly reports fewer answers than it asked for is
1261
1449
  // worse than one that admits the comparison is partial.
1262
- incomplete_note: missing > 0
1263
- ? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
1264
- : null,
1450
+ incomplete_note:
1451
+ missing > 0
1452
+ ? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
1453
+ : null,
1265
1454
  members: trimmed,
1266
1455
  comparison: null,
1267
1456
  cancellable: live,
@@ -1309,7 +1498,16 @@ export class JobManager {
1309
1498
  if (!group) {
1310
1499
  const view = this.groupView(groupId);
1311
1500
  if (!view) return null;
1312
- return { ...view, cancel_effect: 'nothing signalled: this fan-out was started by another severally process or before a restart, and only a live process can cancel its own consultants' };
1501
+ // Started by another server: each member still running there is asked to stop.
1502
+ const asked = (store.loadGroup(groupId)?.job_ids ?? [])
1503
+ .map((jid) => this.cancel(jid))
1504
+ .filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
1505
+ return {
1506
+ ...view,
1507
+ cancel_effect: asked
1508
+ ? `asked the severally process running it to stop ${asked} consultation(s); that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
1509
+ : 'nothing to signal: no consultant in this fan-out is still running',
1510
+ };
1313
1511
  }
1314
1512
  // Count what is actually about to be signalled before signalling it:
1315
1513
  // cancel() short-circuits on a member that has already finished, so a
@@ -1325,11 +1523,12 @@ export class JobManager {
1325
1523
  const total = group.job_ids.length;
1326
1524
  return {
1327
1525
  ...this.#shapeGroup(group, members),
1328
- cancel_effect: signalled === 0
1329
- ? 'nothing to signal: every consultant in this fan-out had already finished'
1330
- : signalled === total
1331
- ? 'every consultant process group in this fan-out was signalled'
1332
- : `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
1526
+ cancel_effect:
1527
+ signalled === 0
1528
+ ? 'nothing to signal: every consultant in this fan-out had already finished'
1529
+ : signalled === total
1530
+ ? 'every consultant process group in this fan-out was signalled'
1531
+ : `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
1333
1532
  };
1334
1533
  }
1335
1534
 
@@ -1345,7 +1544,10 @@ export class JobManager {
1345
1544
  for (const job of live) {
1346
1545
  job.cancelRequested = true;
1347
1546
  for (const fn of job._cancelFns) fn();
1348
- if (job._handle) { handles.push(job._handle); job._handle.stop(); }
1547
+ if (job._handle) {
1548
+ handles.push(job._handle);
1549
+ job._handle.stop();
1550
+ }
1349
1551
  }
1350
1552
  const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
1351
1553
  await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
@@ -1392,8 +1594,7 @@ function inheritedSummary(manifest) {
1392
1594
  return {
1393
1595
  entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
1394
1596
  // Only what an earlier round showed carries the name it had there.
1395
- left_out: manifest.skipped.filter((x) => x.exposed_as)
1396
- .map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
1597
+ left_out: manifest.skipped.filter((x) => x.exposed_as).map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
1397
1598
  files: carried.reduce((n, e) => n + e.files, 0),
1398
1599
  };
1399
1600
  }
@@ -1484,6 +1685,44 @@ function text(value) {
1484
1685
  return t.length > POLICY.output.itemTextMax ? `${t.slice(0, POLICY.output.itemTextMax)}\n…[truncated by severally]` : t;
1485
1686
  }
1486
1687
 
1688
+ // A follow-up restates what the round before was given unless it says
1689
+ // otherwise: the lead sends the new question, not the whole brief again. Only
1690
+ // a key left out is inherited -- one passed, even as an empty list, replaces
1691
+ // it -- and the validation that follows sees the result. Files are not:
1692
+ // copying them again stays expose_from_parent, named in the call (schema.mjs).
1693
+ function inherit(raw, round) {
1694
+ const { brief } = round;
1695
+ // A round kept off the web stays off: the material it carries over is the same.
1696
+ const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1697
+ if (!brief) return out;
1698
+ for (const k of ['objective', 'success_criteria', 'constraints']) {
1699
+ if (
1700
+ out[k] === undefined &&
1701
+ raw.context?.[k] === undefined &&
1702
+ brief[k] != null &&
1703
+ !(Array.isArray(brief[k]) && !brief[k].length)
1704
+ )
1705
+ out[k] = brief[k];
1706
+ }
1707
+ const ctx = { ...(raw.context ?? {}) };
1708
+ for (const k of ['facts', 'proposal', 'counterpoints']) {
1709
+ if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
1710
+ }
1711
+ // The diff expose_diff added is not the lead's material: it is taken again
1712
+ // (or replaced) through expose_from_parent, against the files as they are now.
1713
+ // It is the last artifact (appended after the lead's own), so only that one is dropped.
1714
+ const artifacts = [...(brief.artifacts ?? [])];
1715
+ const last = artifacts.at(-1);
1716
+ if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
1717
+ artifacts.pop();
1718
+ if (ctx.artifacts === undefined && artifacts.length) {
1719
+ // Stored with null for an absent language or source; the request schema takes them as optional strings.
1720
+ ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
1721
+ }
1722
+ out.context = ctx;
1723
+ return out;
1724
+ }
1725
+
1487
1726
  function adviceCaveat(normalized) {
1488
1727
  const { result, quality } = normalized;
1489
1728
  const notes = [];
@@ -1491,12 +1730,17 @@ function adviceCaveat(normalized) {
1491
1730
  notes.push('the consultant reported its evidence as insufficient: treat this as a request for more material, not as advice');
1492
1731
  } else if (result.evidence_basis === 'thin') {
1493
1732
  notes.push('the consultant reported thin evidence: weigh the findings accordingly');
1733
+ } else if (!result.evidence_basis) {
1734
+ notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
1494
1735
  }
1736
+ // Read first in a comparison; a missing one is shown as null there, and named here.
1737
+ if (!result.stance)
1738
+ notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
1495
1739
  if (quality.findings_without_grounds > 0) {
1496
1740
  notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
1497
1741
  }
1498
1742
  if (quality.unsourced && result.findings.length > 0) {
1499
- notes.push('no sources were cited; the findings rest on the consultant\'s own reasoning over the brief');
1743
+ notes.push("no sources were cited; the findings rest on the consultant's own reasoning over the brief");
1500
1744
  }
1501
1745
  return notes.length ? notes.join('; ') : null;
1502
1746
  }
@@ -1511,7 +1755,7 @@ function adviceCaveat(normalized) {
1511
1755
  function sameVendorCaveat(caller, callerModel, consultant) {
1512
1756
  // The lead's lineage: its declared model if that names one, else its CLI's
1513
1757
  // own. An unknown lineage on either side is no caveat -- not a guess.
1514
- const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? CLI_VENDORS[caller] ?? null : null);
1758
+ const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? (CLI_VENDORS[caller] ?? null) : null);
1515
1759
  const vendor = consultant.vendor;
1516
1760
  if (!callerVendor || !vendor || callerVendor !== vendor) return null;
1517
1761
  // Say what a fresh session of the same lineage removes and what it keeps.
@@ -1555,7 +1799,7 @@ function comparison(members, budget) {
1555
1799
  by_target: columns,
1556
1800
  note:
1557
1801
  'This server does not judge whether the consultants agree: similar summaries are not evidence of agreement. ' +
1558
- 'Each stance is the consultant\'s own declaration, relayed as given -- consultants that share a finding can still ' +
1802
+ "Each stance is the consultant's own declaration, relayed as given -- consultants that share a finding can still " +
1559
1803
  'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
1560
1804
  'follow-up only where a divergence would change your decision.',
1561
1805
  };
@@ -1598,7 +1842,10 @@ function byTarget(m, budget) {
1598
1842
  left -= n;
1599
1843
  out.push(v);
1600
1844
  }
1601
- if (out.length < items.length) { omitted[name] = items.length - out.length; truncated = true; }
1845
+ if (out.length < items.length) {
1846
+ omitted[name] = items.length - out.length;
1847
+ truncated = true;
1848
+ }
1602
1849
  return out;
1603
1850
  };
1604
1851
  const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
@@ -1608,15 +1855,31 @@ function byTarget(m, budget) {
1608
1855
  // The findings first, with up to 60% of what is left and their points cut
1609
1856
  // as short as it takes: they are what gets recorded against, so their ids
1610
1857
  // matter most. The other lists then share the rest evenly.
1611
- const findingPoints = list('finding_points', r.findings, 1,
1612
- (f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }), 0.6, 20);
1613
- const decisionChangers = list('decision_changers', r.decision_changers, 2,
1614
- (d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }), 1 / 4);
1858
+ const findingPoints = list(
1859
+ 'finding_points',
1860
+ r.findings,
1861
+ 1,
1862
+ (f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }),
1863
+ 0.6,
1864
+ 20,
1865
+ );
1866
+ const decisionChangers = list(
1867
+ 'decision_changers',
1868
+ r.decision_changers,
1869
+ 2,
1870
+ (d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }),
1871
+ 1 / 4,
1872
+ );
1615
1873
  const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
1616
1874
  const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
1617
1875
  const nextChecks = list('next_checks', r.next_checks, 1, (c, n) => ({ id: c.id, check: clip(c.check, n) }), 1 / 2);
1618
- const disagreements = list('remaining_disagreements', r.remaining_disagreements, 3,
1619
- (d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])), 1);
1876
+ const disagreements = list(
1877
+ 'remaining_disagreements',
1878
+ r.remaining_disagreements,
1879
+ 3,
1880
+ (d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
1881
+ 1,
1882
+ );
1620
1883
  const entry = {
1621
1884
  job_id: m.job_id,
1622
1885
  target: m.target,
@@ -1651,7 +1914,11 @@ function byTarget(m, budget) {
1651
1914
  points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
1652
1915
  };
1653
1916
  // A member's status and failure are in `members`; say them once.
1654
- if (!full) { delete entry.cli; delete entry.status; delete entry.failure_kind; }
1917
+ if (!full) {
1918
+ delete entry.cli;
1919
+ delete entry.status;
1920
+ delete entry.failure_kind;
1921
+ }
1655
1922
  if (Object.keys(omitted).length) entry.omitted = omitted;
1656
1923
  if (truncated) {
1657
1924
  entry.truncated = true;
@@ -1690,12 +1957,17 @@ export function pointsOf(view, ids) {
1690
1957
  const known = recordableIds(r);
1691
1958
  const unknown = ids.filter((id) => !known.includes(id));
1692
1959
  if (unknown.length) {
1693
- throw new RequestError(`${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`, 'unknown_entry_id');
1960
+ throw new RequestError(
1961
+ `${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`,
1962
+ 'unknown_entry_id',
1963
+ );
1694
1964
  }
1695
1965
  const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
1696
1966
  const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
1697
1967
  const points = [...new Set(ids)].map((id) => {
1698
- const [kind, item] = Object.entries(lists).map(([k, l]) => [k, l.find((x) => x.id === id)]).find(([, x]) => x);
1968
+ const [kind, item] = Object.entries(lists)
1969
+ .map(([k, l]) => [k, l.find((x) => x.id === id)])
1970
+ .find(([, x]) => x);
1699
1971
  return { kind, ...item, recorded: verdicts.get(id) ?? null };
1700
1972
  });
1701
1973
  return {
@@ -1774,18 +2046,26 @@ function nextStep(job) {
1774
2046
  // point showed, not when the answer arrives. Name the open ones until then.
1775
2047
  const written = new Set((job.record?.entries ?? []).map((e) => e.id));
1776
2048
  const open = job.result ? recordableIds(job.result).filter((id) => !written.has(id)).length : 0;
1777
- const close = open > 0
1778
- ? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
1779
- : '';
1780
- return (left > 0
1781
- ? `check the grounds behind the points that matter, run the one check that would change the decision, then either decide, or spend one of your ${left} remaining round(s) on the specific divergences (followup_to: "${job.job_id}").`
1782
- : 'rounds exhausted: decide with what you have.') + close;
2049
+ const close =
2050
+ open > 0
2051
+ ? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
2052
+ : '';
2053
+ return (
2054
+ (left > 0
2055
+ ? `check the grounds behind the points that matter, run the one check that would change the decision, then either decide, or spend one of your ${left} remaining round(s) on the specific divergences (followup_to: "${job.job_id}").`
2056
+ : 'rounds exhausted: decide with what you have.') + close
2057
+ );
1783
2058
  }
1784
- if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error') return 'retriable: narrow the brief and start a new consultation';
1785
- if (job.failure?.kind === 'interrupted') return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
1786
- if (job.failure?.kind === 'rate_limited') return 'the consultant was rate-limited and the server already re-ran it (see retries); try again in a few minutes, or proceed on your own judgement';
1787
- if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth') return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
1788
- if (job.failure?.kind === 'invalid_output') return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
2059
+ if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
2060
+ return 'retriable: narrow the brief and start a new consultation';
2061
+ if (job.failure?.kind === 'interrupted')
2062
+ return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
2063
+ if (job.failure?.kind === 'rate_limited')
2064
+ return 'the consultant was rate-limited and the server already re-ran it (see retries); try again in a few minutes, or proceed on your own judgement';
2065
+ if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth')
2066
+ return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
2067
+ if (job.failure?.kind === 'invalid_output')
2068
+ return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
1789
2069
  return 'proceed on your own judgement and note that no peer input was obtained';
1790
2070
  }
1791
2071