severally-mcp 2.3.1 → 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.
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';
@@ -71,13 +80,20 @@ function ownerState(jobId, owner) {
71
80
  if (!owner) return 'dead';
72
81
  let pidAlive;
73
82
  if (owner.pid === process.pid) pidAlive = LIVE_HERE.has(jobId);
74
- 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
+ }
75
90
  if (!pidAlive) return 'dead';
76
91
  return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
77
92
  }
78
93
 
79
94
  export class JobManager {
80
95
  constructor() {
96
+ this._cancelWatch = null;
81
97
  this.jobs = new Map();
82
98
  this.groups = new Map();
83
99
  this.order = [];
@@ -103,7 +119,10 @@ export class JobManager {
103
119
  // owner finished and died before removing the marker.
104
120
  #interrupt(jobId, rec, state) {
105
121
  const done = store.loadRound(jobId);
106
- if (done) { store.removeRunning(jobId); return done; }
122
+ if (done) {
123
+ store.removeRunning(jobId);
124
+ return done;
125
+ }
107
126
  const { owner, ...round } = rec;
108
127
  round.status = 'failed';
109
128
  round.finished_at = new Date().toISOString();
@@ -114,7 +133,11 @@ export class JobManager {
114
133
  retriable: true,
115
134
  delivered_advice: false,
116
135
  };
117
- try { store.persistRound(round); } catch { return round; }
136
+ try {
137
+ store.persistRound(round);
138
+ } catch {
139
+ return round;
140
+ }
118
141
  store.removeRunning(jobId);
119
142
  // An overdue owner may still be running its consultant: leave its working
120
143
  // directory to it. ponytail: if the pid was reused instead, that
@@ -195,6 +218,7 @@ export class JobManager {
195
218
 
196
219
  // `detail: "full"` returns the brief as stored; by default a finished
197
220
  // consultation names it (size, sha256, round file) instead of repeating it.
221
+ /** @param {string} jobId @param {{detail?: string}} [opts] */
198
222
  view(jobId, { detail } = {}) {
199
223
  const job = this.jobs.get(jobId);
200
224
  if (!job) {
@@ -206,9 +230,10 @@ export class JobManager {
206
230
  // While it runs, say what it is doing. "running" for twenty minutes tells
207
231
  // the lead nothing; a consultation visibly grinding through tool calls is
208
232
  // one it can cancel now rather than wait out.
209
- rec.progress = job.status === 'running' && job._handle
210
- ? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
211
- : null;
233
+ rec.progress =
234
+ job.status === 'running' && job._handle
235
+ ? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
236
+ : null;
212
237
  rec.record = job.record ? recordSummary(job) : null;
213
238
  rec.prediction = job.prediction ?? null;
214
239
  rec.reflection = job.reflection ?? null;
@@ -222,7 +247,8 @@ export class JobManager {
222
247
  const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
223
248
  if (live) {
224
249
  rec.brief = null;
225
- 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';
226
252
  }
227
253
  rec.limits = live ? null : limitsSummary();
228
254
  rec.next_step = nextStep(job);
@@ -246,41 +272,46 @@ export class JobManager {
246
272
  const state = ownerState(jobId, live.owner);
247
273
  // A round saved just before its owner died is the answer, whatever the marker says.
248
274
  const saved = state === 'alive' ? null : store.loadRoundAt(live);
249
- elsewhere.push({ rec: state === 'alive' ? live : saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } } });
275
+ elsewhere.push({
276
+ rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
277
+ });
250
278
  }
251
- for (const row of store.indexRows()) if (!seen.has(row.job_id)) { seen.add(row.job_id); elsewhere.push({ row }); }
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
+ }
252
284
  const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
253
285
  const picked = [];
254
286
  // Round files are read only for the rows shown; one that cannot be read is passed over.
255
287
  for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
256
288
  if (picked.length >= limit) break;
257
- const j = x.row ? store.loadRoundAt(x.row) : x.rec ?? x;
289
+ const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
258
290
  if (j) picked.push(j);
259
291
  }
260
- return picked
261
- .map((j) => {
262
- return {
263
- job_id: j.job_id,
264
- group_id: j.group_id ?? null,
265
- chain_id: j.chain_id,
266
- round: j.round,
267
- target: j.target,
268
- cli: j.cli,
269
- model: j.model,
270
- mode: j.mode,
271
- status: j.status,
272
- failure_kind: j.failure?.kind ?? null,
273
- created_at: j.created_at,
274
- duration_ms: j.duration_ms,
275
- summary: j.result ? j.result.summary.slice(0, 200) : null,
276
- recorded: Boolean(j.record),
277
- verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
278
- this_session: this.jobs.has(j.job_id),
279
- predicted: Boolean(j.prediction),
280
- reflected: Boolean(j.reflection),
281
- initiator: j.initiator ?? null,
282
- };
283
- });
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
+ });
284
315
  }
285
316
 
286
317
  /**
@@ -300,7 +331,11 @@ export class JobManager {
300
331
  t.consultations += 1;
301
332
  t.attempts += attempts.length;
302
333
  for (const u of attempts) {
303
- 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
+ }
304
339
  }
305
340
  }
306
341
  for (const [target, t] of Object.entries(by)) {
@@ -318,12 +353,12 @@ export class JobManager {
318
353
  }
319
354
  // A config the operator got wrong is not run on defaults: that could
320
355
  // re-enable a CLI they turned off. Reading the history still works.
321
- 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');
322
358
 
323
359
  // Resolve the chain before validating, so follow-up rules can be applied.
324
- const followupTo = typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim()
325
- ? rawRequest.followup_to.trim()
326
- : null;
360
+ const followupTo =
361
+ typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
327
362
  let parent = null;
328
363
  let fromGroup = null;
329
364
  if (followupTo?.startsWith('group_')) {
@@ -367,7 +402,8 @@ export class JobManager {
367
402
  rawRequest = { ...rawRequest, target: parent.target };
368
403
  }
369
404
  // A fan-out's members were all handed the same brief, so any one of them holds it.
370
- if (parent || fromGroup) rawRequest = inherit(rawRequest, fromGroup ? fromGroup.links[0].groupMember ?? fromGroup.links[0].parent : parent);
405
+ if (parent || fromGroup)
406
+ rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
371
407
 
372
408
  const req = parseRequest(rawRequest, {
373
409
  isFollowup: Boolean(parent || fromGroup),
@@ -411,23 +447,25 @@ export class JobManager {
411
447
  // not, so a suspended laptop must not make a live owner look overdue.
412
448
  owner: {
413
449
  pid: process.pid,
414
- 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(),
415
453
  },
416
454
  }));
417
455
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
418
456
  if (taken.chainBusy) {
419
457
  const { live, mine } = taken.chainBusy;
420
458
  throw new RequestError(
421
- `another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}`
422
- + '; wait for it (consult_get) and follow up the latest round',
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',
423
461
  'followup_not_latest',
424
462
  );
425
463
  }
426
464
  if (!taken.ok) {
427
465
  throw new RequestError(
428
- `this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
429
- + (taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '')
430
- + '. 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',
431
469
  'concurrency_limit',
432
470
  );
433
471
  }
@@ -454,7 +492,11 @@ export class JobManager {
454
492
  for (const jid of group.job_ids) {
455
493
  const member = this.jobs.get(jid) ?? store.loadRound(jid);
456
494
  if (!member) {
457
- 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
+ });
458
500
  continue;
459
501
  }
460
502
  const rounds = this.#chainRounds(member.chain_id);
@@ -463,10 +505,14 @@ export class JobManager {
463
505
  // The name is kept, not re-resolved: a consultant renamed or turned off
464
506
  // since is skipped, never continued by whichever one answers to it now.
465
507
  const consultant = consultantByName(member.target);
466
- const reason = !latest ? `${member.status}, so there is no answer to follow up`
467
- : latest.status !== 'completed' ? `round ${latest.round} is still ${latest.status}`
468
- : nextRound > POLICY.maxRounds ? `has used its ${POLICY.maxRounds} rounds`
469
- : !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'
470
516
  : null;
471
517
  if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
472
518
  else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
@@ -491,7 +537,7 @@ export class JobManager {
491
537
  // missing or a tree that blows the cap is the lead's to fix now rather
492
538
  // than a job that fails having already spent a round of this chain.
493
539
  let exposeManifest = null;
494
- 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 ?? []) : [];
495
541
  if (req.context.expose_from_parent && parentEntries.length === 0) {
496
542
  throw new RequestError(
497
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`,
@@ -503,8 +549,11 @@ export class JobManager {
503
549
  try {
504
550
  fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
505
551
  const plan = planInheritance(parentEntries, req.context.expose_paths);
506
- exposeManifest = materializeExposedPaths(snapshot, plan.paths)
507
- ?? { entries: [], totals: { files: 0, bytes: 0 }, skipped: [] };
552
+ exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
553
+ entries: [],
554
+ totals: { files: 0, bytes: 0 },
555
+ skipped: [],
556
+ };
508
557
  exposeManifest.skipped.push(...plan.skipped);
509
558
  const parentSources = new Set(parentEntries.map((e) => e.source));
510
559
  for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
@@ -522,14 +571,21 @@ export class JobManager {
522
571
  const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
523
572
  const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
524
573
  if (diff) {
525
- req.context.artifacts = [...req.context.artifacts,
526
- { 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
+ ];
527
578
  }
528
579
  }
529
580
  markSinceParent(exposeManifest, parentEntries);
530
581
  // Every earlier path gone, and nothing else to go on: refused, as a
531
582
  // first round with no context is.
532
- 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
+ ) {
533
589
  throw new RequestError(
534
590
  `context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
535
591
  'nothing_to_inherit',
@@ -615,7 +671,11 @@ export class JobManager {
615
671
  job_ids: members.map((m) => m.job_id),
616
672
  };
617
673
  this.groups.set(groupId, group);
618
- try { store.persistGroup(group); } catch { /* history is best effort */ }
674
+ try {
675
+ store.persistGroup(group);
676
+ } catch {
677
+ /* history is best effort */
678
+ }
619
679
 
620
680
  // A fan-out is only comparable if every consultant answered the same
621
681
  // question in the same words: every member's brief is the same request,
@@ -646,9 +706,12 @@ export class JobManager {
646
706
  // consultation, but one about code that hands over no file is one the
647
707
  // consultant answers without having seen the repository.
648
708
  files_exposed: exposeManifest?.totals.files ?? 0,
649
- ...(exposeManifest?.totals.files ? {} : {
650
- 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',
651
- }),
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
+ }),
652
715
  status: 'running',
653
716
  accepted_at: group.created_at,
654
717
  limits: limitsSummary(),
@@ -670,7 +733,11 @@ export class JobManager {
670
733
  ...common,
671
734
  question: group.question, // the redacted text every member was actually sent
672
735
  jobs: members.map((m) => ({
673
- 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,
674
741
  ...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
675
742
  })),
676
743
  poll_with: `consult_get({ group_id: "${groupId}" })`,
@@ -696,10 +763,13 @@ export class JobManager {
696
763
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
697
764
  // The checked points first, then the unchecked ones, each with the
698
765
  // lead's verdict and effect when there is one.
699
- const ordered = (items, shape) => [
700
- ...items.filter((x) => verdicts.has(x.id)),
701
- ...items.filter((x) => !verdicts.has(x.id)),
702
- ].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
+ }));
703
773
  const checked = (x) => x.verdict !== null;
704
774
  const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
705
775
  return {
@@ -797,11 +867,11 @@ export class JobManager {
797
867
  return this.#fail(
798
868
  job,
799
869
  'auth',
800
- `no ${t.label} credential to hand the consultant: nothing at `
801
- + `${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a `
802
- + 'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home '
803
- + 'directory that holds the token, or set one of those variables to authenticate with an API key '
804
- + '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.',
805
875
  );
806
876
  }
807
877
 
@@ -833,8 +903,9 @@ export class JobManager {
833
903
  // What this consultant was actually launched with, read off the
834
904
  // invocation and the sandbox rather than restated from intent, so the
835
905
  // record says what held for this run.
836
- job.boundary = adapter.boundary?.({ args: invocation.args, sandbox, network: req.network,
837
- hasExposedPaths: Boolean(req._snapshot) }) ?? null;
906
+ job.boundary =
907
+ adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
908
+ null;
838
909
 
839
910
  const attempt = (timeout) => {
840
911
  const { handle, done } = runChild({
@@ -876,7 +947,9 @@ export class JobManager {
876
947
  if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
877
948
  try {
878
949
  job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
879
- } catch { /* history is best effort */ }
950
+ } catch {
951
+ /* history is best effort */
952
+ }
880
953
  }
881
954
  if (run.spawnError) {
882
955
  return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
@@ -911,9 +984,12 @@ export class JobManager {
911
984
  // No envelope at all: what stderr holds is the dump from stopping it,
912
985
  // not a message to classify. Not re-run, as it may have spent its quota.
913
986
  if (run.stalled && !interpreted.usageRaw) {
914
- return this.#fail(job, 'cli_error',
987
+ return this.#fail(
988
+ job,
989
+ 'cli_error',
915
990
  `${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
916
- adapter.progress?.(run) ?? null);
991
+ adapter.progress?.(run) ?? null,
992
+ );
917
993
  }
918
994
 
919
995
  const remaining = budgetMs - (Date.now() - t0);
@@ -921,19 +997,35 @@ export class JobManager {
921
997
  authTried = true;
922
998
  const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
923
999
  if (seeded && remaining >= MIN_RETRY_MS) {
924
- 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
+ });
925
1006
  run = await attempt(remaining);
926
1007
  continue;
927
1008
  }
928
1009
  }
929
1010
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
930
- if (AUTO_RETRY_KINDS.includes(interpreted.failureKind) && autoRetries < POLICY.maxAutoRetries
931
- && 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
+ ) {
932
1016
  autoRetries += 1;
933
- 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
+ });
934
1023
  await new Promise((resolve) => {
935
1024
  const timer = setTimeout(resolve, wait);
936
- job._cancelFns.push(() => { clearTimeout(timer); resolve(); });
1025
+ job._cancelFns.push(() => {
1026
+ clearTimeout(timer);
1027
+ resolve();
1028
+ });
937
1029
  });
938
1030
  if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
939
1031
  run = await attempt(budgetMs - (Date.now() - t0));
@@ -943,8 +1035,8 @@ export class JobManager {
943
1035
  }
944
1036
 
945
1037
  // Without an answer, a process left holding the pipes is the failure to report.
946
- const leftover = run.pipesForced
947
- && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
1038
+ const leftover =
1039
+ run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
948
1040
  if (!interpreted.ok) {
949
1041
  job.usage = adapter.usageRecord(interpreted.usageRaw);
950
1042
  if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
@@ -983,8 +1075,10 @@ export class JobManager {
983
1075
  const notes = [
984
1076
  adviceCaveat(normalized),
985
1077
  sameVendorCaveat(job.caller, job.caller_model, consultant),
986
- interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
987
- leftover && '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)',
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)',
988
1082
  ].filter(Boolean);
989
1083
  // No `advice_usable` verdict here. It was always true, so it carried no
990
1084
  // information -- and worse, it read as the server vouching for advice it
@@ -1024,11 +1118,19 @@ export class JobManager {
1024
1118
 
1025
1119
  // The in-progress marker other processes read (see store.writeRunning).
1026
1120
  #mark(job) {
1027
- 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
+ }
1028
1126
  }
1029
1127
 
1030
1128
  #finish(job) {
1031
- 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
+ }
1032
1134
  store.removeRunning(job.job_id);
1033
1135
  store.releaseSeat(job.job_id);
1034
1136
  LIVE_HERE.delete(job.job_id);
@@ -1043,16 +1145,17 @@ export class JobManager {
1043
1145
  // consultant did not produce and a word outside VERDICTS, then stores what
1044
1146
  // was written. It never decides that a finding was adopted, and it never
1045
1147
  // fills in a verdict the lead did not write.
1148
+ /** @param {Record<string, any>} [input] */
1046
1149
  record(input = {}) {
1047
1150
  if (input.records !== undefined) return this.#recordMany(input);
1048
1151
  const allowed = ['job_id', 'entries', 'reflection'];
1049
1152
  const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
1050
1153
  if (unknown.length) {
1051
1154
  throw new RequestError(
1052
- `${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records`
1053
- + (unknown.includes('prediction')
1054
- ? '. 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'
1055
- : ''),
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
+ : ''),
1056
1159
  'unknown_field',
1057
1160
  );
1058
1161
  }
@@ -1069,7 +1172,10 @@ export class JobManager {
1069
1172
  #recordMany(input) {
1070
1173
  const extra = Object.keys(input).filter((k) => k !== 'records');
1071
1174
  if (extra.length) {
1072
- 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
+ );
1073
1179
  }
1074
1180
  const { records } = input;
1075
1181
  if (!Array.isArray(records) || records.length === 0) {
@@ -1080,10 +1186,12 @@ export class JobManager {
1080
1186
  const seen = new Set();
1081
1187
  records.forEach((r, i) => {
1082
1188
  try {
1083
- 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');
1084
1191
  seen.add(r?.job_id);
1085
1192
  const unknown = Object.keys(r ?? {}).filter((k) => !['job_id', 'entries', 'reflection'].includes(k));
1086
- 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');
1087
1195
  changes.push(this.#checkRecord(r ?? {}));
1088
1196
  } catch (err) {
1089
1197
  if (!(err instanceof RequestError)) throw err;
@@ -1104,9 +1212,13 @@ export class JobManager {
1104
1212
 
1105
1213
  // Everything that can be wrong with a record, found before anything is
1106
1214
  // written: what the job's record and reflection become, or a RequestError.
1215
+ /** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
1107
1216
  #checkRecord({ job_id: jobId, entries, reflection }) {
1108
1217
  if (entries === undefined && reflection === undefined) {
1109
- 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
+ );
1110
1222
  }
1111
1223
  // A job this process ran is in memory; one from an earlier session is read
1112
1224
  // back from its round file, so a verdict can still be written once the
@@ -1114,10 +1226,7 @@ export class JobManager {
1114
1226
  const live = this.jobs.get(jobId);
1115
1227
  const job = live ?? store.loadRound(jobId);
1116
1228
  if (!job) {
1117
- throw new RequestError(
1118
- `no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
1119
- 'unknown_job',
1120
- );
1229
+ throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
1121
1230
  }
1122
1231
  if (!job.result) {
1123
1232
  throw new RequestError(
@@ -1180,9 +1289,10 @@ export class JobManager {
1180
1289
  newReflection = { delta, related_item_ids: related, recorded_at: now };
1181
1290
  }
1182
1291
 
1183
- const newRecord = entries === undefined
1184
- ? undefined
1185
- : { 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)) };
1186
1296
  return { job, live, record: newRecord, reflection: newReflection };
1187
1297
  }
1188
1298
 
@@ -1259,7 +1369,8 @@ export class JobManager {
1259
1369
  if (!job) {
1260
1370
  const view = this.view(jobId);
1261
1371
  if (!view) return null;
1262
- if (view.status !== 'running' && view.status !== 'queued') return { ...view, cancel_effect: `job was already ${view.status}` };
1372
+ if (view.status !== 'running' && view.status !== 'queued')
1373
+ return { ...view, cancel_effect: `job was already ${view.status}` };
1263
1374
  return {
1264
1375
  ...view,
1265
1376
  cancel_effect: store.requestCancel(jobId)
@@ -1279,6 +1390,7 @@ export class JobManager {
1279
1390
  // Every field here is derived from the one `members` array it is handed, so a
1280
1391
  // group view can never report one status in `members` and another in
1281
1392
  // `comparison` or `status` for the same job.
1393
+ /** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
1282
1394
  #shapeGroup(group, members, { detail } = {}) {
1283
1395
  const full = detail === 'full';
1284
1396
  const cancelling = members.some((m) => m.status === 'cancelling');
@@ -1295,9 +1407,10 @@ export class JobManager {
1295
1407
  // same thing three times. Hoist one copy to the group and strip it from
1296
1408
  // the members, which keeps the payload readable without losing anything.
1297
1409
  const firstBrief = members.find((m) => m.brief)?.brief ?? null;
1298
- const groupBrief = firstBrief && !full
1299
- ? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
1300
- : 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;
1301
1414
  // The limits are the server's, not a member's: one copy, on the group.
1302
1415
  // By default a member is only its state: what there is to compare is in
1303
1416
  // `comparison`, once, and the full answer is one consult_get({ job_id }) away.
@@ -1305,7 +1418,8 @@ export class JobManager {
1305
1418
  if (full) return rest;
1306
1419
  const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
1307
1420
  const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
1308
- 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));
1309
1423
  return {
1310
1424
  job_id: rest.job_id,
1311
1425
  target: rest.target,
@@ -1321,19 +1435,22 @@ export class JobManager {
1321
1435
  const question = full ? group.question : cut(group.question, QUESTION_CHARS);
1322
1436
  const view = {
1323
1437
  group_id: group.group_id,
1324
- status: cancelling ? 'cancelling' : (live ? 'running' : 'done'),
1438
+ status: cancelling ? 'cancelling' : live ? 'running' : 'done',
1325
1439
  mode: group.mode,
1326
1440
  question,
1327
- ...(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
+ : {}),
1328
1444
  brief: groupBrief,
1329
1445
  members_expected: group.job_ids.length,
1330
1446
  members_available: members.length,
1331
1447
  // History is capped, so an older member can already be gone. Say so:
1332
1448
  // a fan-out that quietly reports fewer answers than it asked for is
1333
1449
  // worse than one that admits the comparison is partial.
1334
- incomplete_note: missing > 0
1335
- ? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
1336
- : 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,
1337
1454
  members: trimmed,
1338
1455
  comparison: null,
1339
1456
  cancellable: live,
@@ -1383,10 +1500,14 @@ export class JobManager {
1383
1500
  if (!view) return null;
1384
1501
  // Started by another server: each member still running there is asked to stop.
1385
1502
  const asked = (store.loadGroup(groupId)?.job_ids ?? [])
1386
- .map((jid) => this.cancel(jid)).filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
1387
- return { ...view, cancel_effect: asked
1388
- ? `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`
1389
- : 'nothing to signal: no consultant in this fan-out is still running' };
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
+ };
1390
1511
  }
1391
1512
  // Count what is actually about to be signalled before signalling it:
1392
1513
  // cancel() short-circuits on a member that has already finished, so a
@@ -1402,11 +1523,12 @@ export class JobManager {
1402
1523
  const total = group.job_ids.length;
1403
1524
  return {
1404
1525
  ...this.#shapeGroup(group, members),
1405
- cancel_effect: signalled === 0
1406
- ? 'nothing to signal: every consultant in this fan-out had already finished'
1407
- : signalled === total
1408
- ? 'every consultant process group in this fan-out was signalled'
1409
- : `${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`,
1410
1532
  };
1411
1533
  }
1412
1534
 
@@ -1422,7 +1544,10 @@ export class JobManager {
1422
1544
  for (const job of live) {
1423
1545
  job.cancelRequested = true;
1424
1546
  for (const fn of job._cancelFns) fn();
1425
- if (job._handle) { handles.push(job._handle); job._handle.stop(); }
1547
+ if (job._handle) {
1548
+ handles.push(job._handle);
1549
+ job._handle.stop();
1550
+ }
1426
1551
  }
1427
1552
  const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
1428
1553
  await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
@@ -1469,8 +1594,7 @@ function inheritedSummary(manifest) {
1469
1594
  return {
1470
1595
  entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
1471
1596
  // Only what an earlier round showed carries the name it had there.
1472
- left_out: manifest.skipped.filter((x) => x.exposed_as)
1473
- .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 })),
1474
1598
  files: carried.reduce((n, e) => n + e.files, 0),
1475
1599
  };
1476
1600
  }
@@ -1572,7 +1696,13 @@ function inherit(raw, round) {
1572
1696
  const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1573
1697
  if (!brief) return out;
1574
1698
  for (const k of ['objective', 'success_criteria', 'constraints']) {
1575
- if (out[k] === undefined && raw.context?.[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) out[k] = brief[k];
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];
1576
1706
  }
1577
1707
  const ctx = { ...(raw.context ?? {}) };
1578
1708
  for (const k of ['facts', 'proposal', 'counterpoints']) {
@@ -1583,7 +1713,8 @@ function inherit(raw, round) {
1583
1713
  // It is the last artifact (appended after the lead's own), so only that one is dropped.
1584
1714
  const artifacts = [...(brief.artifacts ?? [])];
1585
1715
  const last = artifacts.at(-1);
1586
- if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff ')) artifacts.pop();
1716
+ if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
1717
+ artifacts.pop();
1587
1718
  if (ctx.artifacts === undefined && artifacts.length) {
1588
1719
  // Stored with null for an absent language or source; the request schema takes them as optional strings.
1589
1720
  ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
@@ -1603,12 +1734,13 @@ function adviceCaveat(normalized) {
1603
1734
  notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
1604
1735
  }
1605
1736
  // Read first in a comparison; a missing one is shown as null there, and named here.
1606
- if (!result.stance) notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
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');
1607
1739
  if (quality.findings_without_grounds > 0) {
1608
1740
  notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
1609
1741
  }
1610
1742
  if (quality.unsourced && result.findings.length > 0) {
1611
- 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");
1612
1744
  }
1613
1745
  return notes.length ? notes.join('; ') : null;
1614
1746
  }
@@ -1623,7 +1755,7 @@ function adviceCaveat(normalized) {
1623
1755
  function sameVendorCaveat(caller, callerModel, consultant) {
1624
1756
  // The lead's lineage: its declared model if that names one, else its CLI's
1625
1757
  // own. An unknown lineage on either side is no caveat -- not a guess.
1626
- 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);
1627
1759
  const vendor = consultant.vendor;
1628
1760
  if (!callerVendor || !vendor || callerVendor !== vendor) return null;
1629
1761
  // Say what a fresh session of the same lineage removes and what it keeps.
@@ -1667,7 +1799,7 @@ function comparison(members, budget) {
1667
1799
  by_target: columns,
1668
1800
  note:
1669
1801
  'This server does not judge whether the consultants agree: similar summaries are not evidence of agreement. ' +
1670
- '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 " +
1671
1803
  'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
1672
1804
  'follow-up only where a divergence would change your decision.',
1673
1805
  };
@@ -1710,7 +1842,10 @@ function byTarget(m, budget) {
1710
1842
  left -= n;
1711
1843
  out.push(v);
1712
1844
  }
1713
- 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
+ }
1714
1849
  return out;
1715
1850
  };
1716
1851
  const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
@@ -1720,15 +1855,31 @@ function byTarget(m, budget) {
1720
1855
  // The findings first, with up to 60% of what is left and their points cut
1721
1856
  // as short as it takes: they are what gets recorded against, so their ids
1722
1857
  // matter most. The other lists then share the rest evenly.
1723
- const findingPoints = list('finding_points', r.findings, 1,
1724
- (f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }), 0.6, 20);
1725
- const decisionChangers = list('decision_changers', r.decision_changers, 2,
1726
- (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
+ );
1727
1873
  const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
1728
1874
  const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
1729
1875
  const nextChecks = list('next_checks', r.next_checks, 1, (c, n) => ({ id: c.id, check: clip(c.check, n) }), 1 / 2);
1730
- const disagreements = list('remaining_disagreements', r.remaining_disagreements, 3,
1731
- (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
+ );
1732
1883
  const entry = {
1733
1884
  job_id: m.job_id,
1734
1885
  target: m.target,
@@ -1763,7 +1914,11 @@ function byTarget(m, budget) {
1763
1914
  points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
1764
1915
  };
1765
1916
  // A member's status and failure are in `members`; say them once.
1766
- 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
+ }
1767
1922
  if (Object.keys(omitted).length) entry.omitted = omitted;
1768
1923
  if (truncated) {
1769
1924
  entry.truncated = true;
@@ -1802,12 +1957,17 @@ export function pointsOf(view, ids) {
1802
1957
  const known = recordableIds(r);
1803
1958
  const unknown = ids.filter((id) => !known.includes(id));
1804
1959
  if (unknown.length) {
1805
- 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
+ );
1806
1964
  }
1807
1965
  const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
1808
1966
  const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
1809
1967
  const points = [...new Set(ids)].map((id) => {
1810
- 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);
1811
1971
  return { kind, ...item, recorded: verdicts.get(id) ?? null };
1812
1972
  });
1813
1973
  return {
@@ -1886,18 +2046,26 @@ function nextStep(job) {
1886
2046
  // point showed, not when the answer arrives. Name the open ones until then.
1887
2047
  const written = new Set((job.record?.entries ?? []).map((e) => e.id));
1888
2048
  const open = job.result ? recordableIds(job.result).filter((id) => !written.has(id)).length : 0;
1889
- const close = open > 0
1890
- ? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
1891
- : '';
1892
- return (left > 0
1893
- ? `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}").`
1894
- : '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
+ );
1895
2058
  }
1896
- if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error') return 'retriable: narrow the brief and start a new consultation';
1897
- 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';
1898
- 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';
1899
- 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';
1900
- 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';
1901
2069
  return 'proceed on your own judgement and note that no peer input was obtained';
1902
2070
  }
1903
2071