severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
package/src/jobs.mjs CHANGED
@@ -4,8 +4,18 @@
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';
8
- import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
7
+ import {
8
+ POLICY,
9
+ VERDICTS,
10
+ limitsSummary,
11
+ timeoutMs,
12
+ detectCaller,
13
+ configProblem,
14
+ consultantByName,
15
+ consultantNamed,
16
+ consultantAvailable,
17
+ } from './policy.mjs';
18
+ import { vendorOf, sameModel, CLI_VENDORS, CLI_IDS } from './consultants.mjs';
9
19
  import { parseRequest, RequestError, charCount } from './schema.mjs';
10
20
  import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
11
21
  import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
@@ -20,6 +30,118 @@ import * as claudeCode from './adapters/claude-code.mjs';
20
30
  import * as antigravity from './adapters/antigravity.mjs';
21
31
  import * as opencode from './adapters/opencode.mjs';
22
32
 
33
+ /** @typedef {import('./run.mjs').Adapter} Adapter */
34
+ /** @typedef {import('./run.mjs').ProcHandle} ProcHandle */
35
+ /** @typedef {import('./consultants.mjs').Consultant} Consultant */
36
+ /** @typedef {import('./consultants.mjs').CliId} CliId */
37
+ /** @typedef {import('./store.mjs').Group} Group */
38
+ /** @typedef {import('./store.mjs').Seat} Seat */
39
+ /** @typedef {NonNullable<Seat['owner']>} JobOwner Who runs a consultation: the severally process, and when it counts as overdue. */
40
+ /** @typedef {import('./brief.mjs').PriorRound} PriorRound */
41
+ /** @typedef {import('./schema.mjs').ParsedRequest & {_snapshot?: string}} ParsedRequest A validated request (see parseRequest), with the copy of the exposed files the job manager adds. */
42
+
43
+ /**
44
+ * The tool arguments, not yet validated; only the keys inherit() and start() look at are named.
45
+ * @typedef {{target?: unknown, targets?: unknown[] | null | undefined, network?: unknown, context?: Record<string, unknown> | null | undefined, [key: string]: unknown}} RawRequest
46
+ */
47
+
48
+ /** @typedef {import('./store.mjs').JobFailure} JobFailure */
49
+ /** @typedef {import('./store.mjs').RecordEntry} RecordEntry */
50
+ /** @typedef {import('./store.mjs').JobRecord} JobRecord */
51
+ /** @typedef {import('./store.mjs').Reflection} Reflection */
52
+ /** @typedef {import('./store.mjs').Prediction} Prediction */
53
+ /** @typedef {import('./store.mjs').ResultItem} ResultItem */
54
+ /** @typedef {import('./store.mjs').ConsultResult} ConsultResult */
55
+ /** @typedef {import('./store.mjs').Round} Round */
56
+ /** @typedef {import('./store.mjs').BriefRecord} BriefRecord */
57
+ /** @typedef {import('./store.mjs').RetryRecord} RetryRecord */
58
+ /** @typedef {import('./store.mjs').IndexRow} IndexRow */
59
+ /** @typedef {import('./store.mjs').BriefExposeEntry} BriefExposeEntry */
60
+
61
+ /** @typedef {import('./store.mjs').RunningRecord} StoredRound A round, or the marker of one still running elsewhere. */
62
+
63
+ /**
64
+ * A consultation this process runs: the round, plus what only a live process has.
65
+ * @typedef {Round & {group_id: string, caller: string | null, retries: RetryRecord[], cancelRequested: boolean, _cancelFns: Array<() => void>, _request: ParsedRequest, _owner?: JobOwner | undefined, _handle?: ProcHandle | undefined, promise?: Promise<unknown>}} Job
66
+ */
67
+
68
+ /**
69
+ * What a view adds to a round.
70
+ * @typedef {object} ViewExtras
71
+ * @property {boolean} [cancellable]
72
+ * @property {string | null} [progress]
73
+ * @property {string} [brief_note]
74
+ * @property {ReturnType<typeof limitsSummary> | null} [limits]
75
+ * @property {string} [next_step]
76
+ * @property {string} [cancel_effect]
77
+ */
78
+
79
+ /**
80
+ * The brief as consult_get names it by default: its size and sha256, where it is kept, what was copied.
81
+ * @typedef {object} BriefSummary
82
+ * @property {number} chars
83
+ * @property {string} sha256
84
+ * @property {string} history_file
85
+ * @property {({skipped: Record<string, number>} & import('./expose-paths.mjs').ExposeTotals) | null} exposed
86
+ * @property {string} full_with
87
+ */
88
+
89
+ /** @typedef {Round & ViewExtras} FullView A round as consult_get shows it with `detail: "full"`. */
90
+
91
+ /** @typedef {Omit<Round, 'brief'> & ViewExtras & {brief: BriefRecord | BriefSummary | null}} JobView A round as consult_get shows it. */
92
+
93
+ /**
94
+ * A consult_record call, or one of its `records`, as sent: not yet validated. The job_id is only ever looked up, so it is not narrowed.
95
+ * @typedef {{job_id?: any, entries?: Array<{id?: unknown, verdict?: unknown, effect?: unknown, note?: unknown} | null>, reflection?: {delta?: unknown, related_item_ids?: unknown} | null, [key: string]: unknown}} RawRecord
96
+ */
97
+
98
+ /** @typedef {{job: Round, live: Job | undefined, entries?: RecordEntry[] | undefined, reflection?: Reflection | undefined}} RecordChange What a record call would write, checked and not yet written: only the entries this call names. */
99
+
100
+ /** @typedef {import('./expose-paths.mjs').ExposeManifest} ExposeManifest */
101
+
102
+ /**
103
+ * @typedef {object} GroupLink
104
+ * @property {Round & {nextRound: number}} parent
105
+ * @property {number} round
106
+ * @property {Round} [groupMember]
107
+ */
108
+
109
+ /**
110
+ * A follow-up to a fan-out: the group, where each member goes on from, and
111
+ * the members that cannot take one.
112
+ * @typedef {object} GroupParents
113
+ * @property {Group} group
114
+ * @property {GroupLink[]} links
115
+ * @property {Array<{job_id: string, target: string | null, reason: string}>} skipped
116
+ */
117
+
118
+ /**
119
+ * One consultant's column of a comparison.
120
+ * @typedef {object} ComparisonColumn
121
+ * @property {string} job_id
122
+ * @property {string} target
123
+ * @property {string} [cli]
124
+ * @property {string} [status]
125
+ * @property {string | null} [failure_kind]
126
+ * @property {string | null} stance
127
+ * @property {string | null} confidence
128
+ * @property {string | null} evidence_basis
129
+ * @property {string | null | undefined} summary
130
+ * @property {unknown[]} finding_points
131
+ * @property {Record<string, number>} severity_counts
132
+ * @property {unknown[]} decision_changers
133
+ * @property {unknown[]} alternative_options
134
+ * @property {unknown[]} unknowns
135
+ * @property {unknown[]} next_checks
136
+ * @property {unknown[]} remaining_disagreements
137
+ * @property {string | null | undefined} caveat
138
+ * @property {number | null} points_without_verdict
139
+ * @property {Record<string, number>} [omitted]
140
+ * @property {boolean} [truncated]
141
+ * @property {string} [full_with]
142
+ */
143
+
144
+ /** @type {Record<CliId, Adapter> & Record<string, Adapter | undefined>} */
23
145
  const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
24
146
 
25
147
  // The recovery respawn runs only while this much of the original budget is
@@ -46,6 +168,7 @@ const API_KEY_VARS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
46
168
  const CREDENTIAL_FILE_VARS = ['GOOGLE_APPLICATION_CREDENTIALS'];
47
169
  const ALL_API_CREDENTIAL_VARS = [...API_KEY_VARS, ...CREDENTIAL_FILE_VARS];
48
170
 
171
+ /** @param {Record<string, string | undefined>} env */
49
172
  function hasApiCredential(env) {
50
173
  if (API_KEY_VARS.some((name) => env[name])) return true;
51
174
  return CREDENTIAL_FILE_VARS.some((name) => env[name] && fs.existsSync(env[name]));
@@ -55,11 +178,12 @@ function hasApiCredential(env) {
55
178
  // listed as not reported rather than silently missing from the totals.
56
179
  const USAGE_FIGURES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens', 'cost_usd'];
57
180
 
58
- const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
181
+ const id = (/** @type {string} */ prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
59
182
 
60
183
  // Jobs this process is running, across every JobManager in it. A marker owned
61
184
  // by this pid but missing here was left by an earlier process that had the
62
185
  // same pid, so it is as dead as one whose pid no longer exists.
186
+ /** @type {Set<string>} */
63
187
  const LIVE_HERE = new Set();
64
188
 
65
189
  // What became of the process that started a consultation: 'alive', 'dead',
@@ -67,19 +191,35 @@ const LIVE_HERE = new Set();
67
191
  // (budget, retries included, plus the kill grace), so either that process is
68
192
  // stuck or the pid now belongs to something else. Nothing is killed on the
69
193
  // strength of this.
194
+ /**
195
+ * @param {string} jobId
196
+ * @param {JobOwner | null | undefined} owner
197
+ * @returns {'alive' | 'dead' | 'overdue'}
198
+ */
70
199
  function ownerState(jobId, owner) {
71
200
  if (!owner) return 'dead';
72
201
  let pidAlive;
73
202
  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'; }
203
+ else
204
+ try {
205
+ process.kill(owner.pid, 0);
206
+ pidAlive = true;
207
+ } catch (err) {
208
+ pidAlive = err instanceof Error && 'code' in err && err.code === 'EPERM';
209
+ }
75
210
  if (!pidAlive) return 'dead';
76
211
  return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
77
212
  }
78
213
 
79
214
  export class JobManager {
80
215
  constructor() {
216
+ /** @type {NodeJS.Timeout | null} */
217
+ this._cancelWatch = null;
218
+ /** @type {Map<string, Job>} */
81
219
  this.jobs = new Map();
220
+ /** @type {Map<string, Group>} */
82
221
  this.groups = new Map();
222
+ /** @type {string[]} */
83
223
  this.order = [];
84
224
  store.ensureDirs();
85
225
  }
@@ -87,6 +227,10 @@ export class JobManager {
87
227
  // A consultation this process does not hold: finished (its round file), or
88
228
  // still in progress (its marker). A marker whose owner is gone becomes a
89
229
  // failed round, `interrupted`, written once by whichever process claims it.
230
+ /**
231
+ * @param {string} jobId
232
+ * @returns {StoredRound | null}
233
+ */
90
234
  #fromDisk(jobId) {
91
235
  const done = store.loadRound(jobId);
92
236
  if (done) return done;
@@ -101,9 +245,18 @@ export class JobManager {
101
245
  // something the next reader finishes: the round is written (the index
102
246
  // takes it once), then the marker goes. A round already on disk wins -- its
103
247
  // owner finished and died before removing the marker.
248
+ /**
249
+ * @param {string} jobId
250
+ * @param {StoredRound} rec
251
+ * @param {'alive' | 'dead' | 'overdue'} state
252
+ * @returns {Round}
253
+ */
104
254
  #interrupt(jobId, rec, state) {
105
255
  const done = store.loadRound(jobId);
106
- if (done) { store.removeRunning(jobId); return done; }
256
+ if (done) {
257
+ store.removeRunning(jobId);
258
+ return done;
259
+ }
107
260
  const { owner, ...round } = rec;
108
261
  round.status = 'failed';
109
262
  round.finished_at = new Date().toISOString();
@@ -114,7 +267,11 @@ export class JobManager {
114
267
  retriable: true,
115
268
  delivered_advice: false,
116
269
  };
117
- try { store.persistRound(round); } catch { return round; }
270
+ try {
271
+ store.persistRound(round);
272
+ } catch {
273
+ return round;
274
+ }
118
275
  store.removeRunning(jobId);
119
276
  // An overdue owner may still be running its consultant: leave its working
120
277
  // directory to it. ponytail: if the pid was reused instead, that
@@ -128,22 +285,26 @@ export class JobManager {
128
285
  for (const jobId of store.listRunning()) {
129
286
  const rec = store.loadRunning(jobId);
130
287
  const state = rec && ownerState(jobId, rec.owner);
131
- if (rec && state !== 'alive') this.#interrupt(jobId, rec, state);
288
+ if (rec && state && state !== 'alive') this.#interrupt(jobId, rec, state);
132
289
  }
133
290
  }
134
291
 
292
+ /**
293
+ * @param {StoredRound} rec
294
+ * @returns {FullView}
295
+ */
135
296
  #diskView(rec) {
136
- const elsewhere = Boolean(rec.owner);
137
297
  const { owner, ...view } = rec;
138
298
  return {
139
299
  ...view,
140
- cancellable: false,
300
+ // consult_cancel asks the process that owns it to stop it.
301
+ cancellable: Boolean(owner) && (view.status === 'running' || view.status === 'queued'),
141
302
  progress: null,
142
303
  record: view.record ? recordSummary(view) : null,
143
- brief: elsewhere ? null : view.brief,
144
- limits: elsewhere ? null : limitsSummary(),
145
- next_step: elsewhere
146
- ? `running in another severally process (pid ${owner.pid}); poll again later -- it can only be cancelled from that process`
304
+ brief: owner ? null : view.brief,
305
+ limits: owner ? null : limitsSummary(),
306
+ next_step: owner
307
+ ? `running in another severally process (pid ${owner.pid}); poll again later, or stop it with consult_cancel`
147
308
  : nextStep(view),
148
309
  };
149
310
  }
@@ -152,6 +313,10 @@ export class JobManager {
152
313
  return [...this.jobs.values()].filter((j) => j.status === 'running');
153
314
  }
154
315
 
316
+ /**
317
+ * @param {Job} job
318
+ * @returns {Round}
319
+ */
155
320
  #record(job) {
156
321
  return {
157
322
  job_id: job.job_id,
@@ -195,20 +360,36 @@ export class JobManager {
195
360
 
196
361
  // `detail: "full"` returns the brief as stored; by default a finished
197
362
  // consultation names it (size, sha256, round file) instead of repeating it.
363
+ /**
364
+ * @param {string} jobId
365
+ * @param {{detail?: string | undefined}} [opts]
366
+ * @returns {JobView | null}
367
+ */
198
368
  view(jobId, { detail } = {}) {
199
369
  const job = this.jobs.get(jobId);
200
370
  if (!job) {
201
371
  const rec = this.#fromDisk(jobId);
202
372
  return rec ? compactBrief(this.#diskView(rec), detail) : null;
203
373
  }
374
+ return this.#jobView(job, detail);
375
+ }
376
+
377
+ /**
378
+ * @param {Job} job
379
+ * @param {string | undefined} detail
380
+ * @returns {JobView}
381
+ */
382
+ #jobView(job, detail) {
383
+ /** @type {FullView} */
204
384
  const rec = this.#record(job);
205
385
  rec.cancellable = job.status === 'running' || job.status === 'queued';
206
386
  // While it runs, say what it is doing. "running" for twenty minutes tells
207
387
  // the lead nothing; a consultation visibly grinding through tool calls is
208
388
  // 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;
389
+ rec.progress =
390
+ job.status === 'running' && job._handle
391
+ ? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
392
+ : null;
212
393
  rec.record = job.record ? recordSummary(job) : null;
213
394
  rec.prediction = job.prediction ?? null;
214
395
  rec.reflection = job.reflection ?? null;
@@ -222,7 +403,8 @@ export class JobManager {
222
403
  const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
223
404
  if (live) {
224
405
  rec.brief = null;
225
- rec.brief_note = 'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
406
+ rec.brief_note =
407
+ 'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
226
408
  }
227
409
  rec.limits = live ? null : limitsSummary();
228
410
  rec.next_step = nextStep(job);
@@ -233,10 +415,18 @@ export class JobManager {
233
415
  // job_id and group_id can still be followed up and recorded against. The
234
416
  // index only picks and orders; each row shown is read from its round, which
235
417
  // holds the verdicts written since. Memory wins over disk for the same job.
418
+ /** @param {{limit?: number}} [opts] */
236
419
  list({ limit = 20 } = {}) {
237
- const here = this.order.map((jid) => this.jobs.get(jid));
238
- const seen = new Set(here.map((j) => j.job_id));
239
- const elsewhere = [];
420
+ // Sorted by when each was created; a round file is read only for the rows shown.
421
+ /** @type {Array<{at: string, load: () => Round | null}>} */
422
+ const candidates = [];
423
+ const seen = new Set();
424
+ for (const jid of this.order) {
425
+ const job = this.jobs.get(jid);
426
+ if (!job) continue;
427
+ seen.add(job.job_id);
428
+ candidates.push({ at: job.created_at ?? '', load: () => job });
429
+ }
240
430
  // Read only: a marker whose owner is gone is shown as interrupted, and
241
431
  // left for consult_get or the startup sweep to close out.
242
432
  for (const jobId of store.listRunning()) {
@@ -246,41 +436,49 @@ export class JobManager {
246
436
  const state = ownerState(jobId, live.owner);
247
437
  // A round saved just before its owner died is the answer, whatever the marker says.
248
438
  const saved = state === 'alive' ? null : store.loadRoundAt(live);
249
- elsewhere.push({ rec: state === 'alive' ? live : saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } } });
439
+ // The marker of a round whose owner is gone: shown as interrupted, with no failure detail to give.
440
+ const rec =
441
+ state === 'alive'
442
+ ? live
443
+ : (saved ?? /** @type {Round} */ ({ ...live, status: 'failed', failure: { kind: 'interrupted' } }));
444
+ candidates.push({ at: rec.created_at ?? '', load: () => rec });
250
445
  }
251
- for (const row of store.indexRows()) if (!seen.has(row.job_id)) { seen.add(row.job_id); elsewhere.push({ row }); }
252
- const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
446
+ for (const row of store.indexRows())
447
+ if (!seen.has(row.job_id)) {
448
+ seen.add(row.job_id);
449
+ candidates.push({ at: row.created_at ?? '', load: () => store.loadRoundAt(row) });
450
+ }
451
+ /** @type {Round[]} */
253
452
  const picked = [];
254
- // Round files are read only for the rows shown; one that cannot be read is passed over.
255
- for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
453
+ // One that cannot be read is passed over.
454
+ for (const x of candidates.sort((p, q) => (q.at < p.at ? -1 : q.at > p.at ? 1 : 0))) {
256
455
  if (picked.length >= limit) break;
257
- const j = x.row ? store.loadRoundAt(x.row) : x.rec ?? x;
456
+ const j = x.load();
258
457
  if (j) picked.push(j);
259
458
  }
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
- });
459
+ return picked.map((j) => {
460
+ return {
461
+ job_id: j.job_id,
462
+ group_id: j.group_id ?? null,
463
+ chain_id: j.chain_id,
464
+ round: j.round,
465
+ target: j.target,
466
+ cli: j.cli,
467
+ model: j.model,
468
+ mode: j.mode,
469
+ status: j.status,
470
+ failure_kind: j.failure?.kind ?? null,
471
+ created_at: j.created_at,
472
+ duration_ms: j.duration_ms,
473
+ summary: j.result ? j.result.summary.slice(0, 200) : null,
474
+ recorded: Boolean(j.record),
475
+ verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
476
+ this_session: this.jobs.has(j.job_id),
477
+ predicted: Boolean(j.prediction),
478
+ reflected: Boolean(j.reflection),
479
+ initiator: j.initiator ?? null,
480
+ };
481
+ });
284
482
  }
285
483
 
286
484
  /**
@@ -290,25 +488,34 @@ export class JobManager {
290
488
  * but named under `not_reported`. Nothing is estimated.
291
489
  */
292
490
  usageTotals() {
491
+ /** @type {Record<string, Record<string, number> & {consultations: number, attempts: number}>} */
293
492
  const by = {};
493
+ /** @type {Record<string, Set<string>>} */
294
494
  const reported = {};
295
495
  for (const j of this.jobs.values()) {
296
- const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter(Boolean);
496
+ const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter((u) => u != null);
297
497
  if (!attempts.length) continue;
298
498
  const t = (by[j.target] ??= { consultations: 0, attempts: 0 });
299
499
  const seen = (reported[j.target] ??= new Set());
300
500
  t.consultations += 1;
301
501
  t.attempts += attempts.length;
302
502
  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); }
503
+ for (const [k, v] of Object.entries(u))
504
+ if (typeof v === 'number') {
505
+ t[k] = (t[k] ?? 0) + v;
506
+ seen.add(k);
507
+ }
304
508
  }
305
509
  }
510
+ /** @type {Record<string, Record<string, number | string[]>>} */
511
+ const out = {};
306
512
  for (const [target, t] of Object.entries(by)) {
307
- t.not_reported = USAGE_FIGURES.filter((k) => !reported[target].has(k));
513
+ out[target] = { ...t, not_reported: USAGE_FIGURES.filter((k) => !reported[target]?.has(k)) };
308
514
  }
309
- return by;
515
+ return out;
310
516
  }
311
517
 
518
+ /** @param {RawRequest} rawRequest the tool arguments, not yet validated */
312
519
  start(rawRequest) {
313
520
  if (process.env.SEVERALLY_ACTIVE === '1') {
314
521
  throw new RequestError(
@@ -318,13 +525,15 @@ export class JobManager {
318
525
  }
319
526
  // A config the operator got wrong is not run on defaults: that could
320
527
  // 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');
528
+ if (configProblem())
529
+ throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
322
530
 
323
531
  // 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;
532
+ const followupTo =
533
+ typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
534
+ /** @type {(Round & {nextRound: number}) | null} */
327
535
  let parent = null;
536
+ /** @type {GroupParents | null} */
328
537
  let fromGroup = null;
329
538
  if (followupTo?.startsWith('group_')) {
330
539
  // A follow-up to a fan-out goes to every member that can take one; the
@@ -340,11 +549,11 @@ export class JobManager {
340
549
  } else if (followupTo) {
341
550
  // A job from an earlier server session is read back from the history,
342
551
  // so a restart does not end a consultation the lead is still checking.
343
- parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
344
- if (!parent) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
345
- if (parent.status !== 'completed') {
552
+ const found = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
553
+ if (!found) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
554
+ if (found.status !== 'completed') {
346
555
  throw new RequestError(
347
- `followup_to job "${followupTo}" is ${parent.status}; only a completed consultation can be followed up`,
556
+ `followup_to job "${followupTo}" is ${found.status}; only a completed consultation can be followed up`,
348
557
  'followup_not_completed',
349
558
  );
350
559
  }
@@ -352,22 +561,24 @@ export class JobManager {
352
561
  // round goes after every round already there: numbering it parent + 1
353
562
  // would overwrite a later round's history file. A later round that
354
563
  // failed or was cancelled does not block a retry from the parent.
355
- const rounds = this.#chainRounds(parent.chain_id);
356
- const later = rounds.find((j) => j.round > parent.round && !['failed', 'cancelled'].includes(j.status));
564
+ const rounds = this.#chainRounds(found.chain_id);
565
+ const later = rounds.find((j) => j.round > found.round && !['failed', 'cancelled'].includes(j.status));
357
566
  if (later) {
358
567
  throw new RequestError(
359
- `job "${followupTo}" is round ${parent.round}, and this consultation already has round ${later.round} (job "${later.job_id}", ${later.status}); follow up the latest round`,
568
+ `job "${followupTo}" is round ${found.round}, and this consultation already has round ${later.round} (job "${later.job_id}", ${later.status}); follow up the latest round`,
360
569
  'followup_not_latest',
361
570
  );
362
571
  }
363
- parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
572
+ parent = { ...found, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
364
573
  }
365
574
 
366
575
  if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
367
576
  rawRequest = { ...rawRequest, target: parent.target };
368
577
  }
369
578
  // 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);
579
+ const first = fromGroup?.links[0];
580
+ const inheritFrom = first ? (first.groupMember ?? first.parent) : parent;
581
+ if (inheritFrom) rawRequest = inherit(rawRequest, inheritFrom);
371
582
 
372
583
  const req = parseRequest(rawRequest, {
373
584
  isFollowup: Boolean(parent || fromGroup),
@@ -403,37 +614,43 @@ export class JobManager {
403
614
  // A follow-up's seat also names the chain and round it opens, so that two
404
615
  // servers continuing one consultation at once cannot both take a round
405
616
  // number: neither sees the other's round in its history until it ends.
406
- const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
407
- const seats = req.targets.map((target, i) => ({
408
- job_id: id('job'),
409
- ...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
410
- // Twice the budget plus an hour: the deadline is wall-clock, the budget is
411
- // not, so a suspended laptop must not make a live owner look overdue.
412
- owner: {
413
- pid: process.pid,
414
- deadline_at: new Date(Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000).toISOString(),
415
- },
416
- }));
617
+ const chainOf = (/** @type {number} */ i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
618
+ const seats = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
619
+ const link = chainOf(i);
620
+ return {
621
+ job_id: id('job'),
622
+ ...(link ? { chain_id: link.parent.chain_id, round: link.round } : {}),
623
+ // Twice the budget plus an hour: the deadline is wall-clock, the budget is
624
+ // not, so a suspended laptop must not make a live owner look overdue.
625
+ owner: {
626
+ pid: process.pid,
627
+ deadline_at: new Date(
628
+ Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
629
+ ).toISOString(),
630
+ },
631
+ };
632
+ });
417
633
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
418
634
  if (taken.chainBusy) {
419
635
  const { live, mine } = taken.chainBusy;
420
636
  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',
637
+ `another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}` +
638
+ '; wait for it (consult_get) and follow up the latest round',
423
639
  'followup_not_latest',
424
640
  );
425
641
  }
426
642
  if (!taken.ok) {
427
643
  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',
644
+ `this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}` +
645
+ (taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '') +
646
+ '. Wait for one to finish (consult_get) or cancel it',
431
647
  'concurrency_limit',
432
648
  );
433
649
  }
434
650
  // A request refused from here on gives its seats back.
435
651
  try {
436
652
  // Each target's parent round and the round it opens, in req.targets order.
653
+ /** @type {Array<{parent: Round | null, round: number, groupMember?: Round}>} */
437
654
  const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
438
655
  return this.#admitted(req, links, seats, fromGroup);
439
656
  } catch (err) {
@@ -446,29 +663,50 @@ export class JobManager {
446
663
  // round of its own chain. A member that is still running, never answered or
447
664
  // has used its rounds is skipped and named; if that is every member, the
448
665
  // request is refused.
666
+ /**
667
+ * @param {string} groupId
668
+ * @returns {GroupParents}
669
+ */
449
670
  #groupParents(groupId) {
450
671
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
451
672
  if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
673
+ /** @type {GroupLink[]} */
452
674
  const links = [];
675
+ /** @type {GroupParents['skipped']} */
453
676
  const skipped = [];
454
677
  for (const jid of group.job_ids) {
455
678
  const member = this.jobs.get(jid) ?? store.loadRound(jid);
456
679
  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' });
680
+ skipped.push({
681
+ job_id: jid,
682
+ target: null,
683
+ reason: 'not found: aged out of the history, or still running in another severally process',
684
+ });
458
685
  continue;
459
686
  }
460
687
  const rounds = this.#chainRounds(member.chain_id);
461
688
  const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
689
+ if (!latest) {
690
+ skipped.push({
691
+ job_id: member.job_id,
692
+ target: member.target,
693
+ reason: `${member.status}, so there is no answer to follow up`,
694
+ });
695
+ continue;
696
+ }
462
697
  const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
463
698
  // The name is kept, not re-resolved: a consultant renamed or turned off
464
699
  // since is skipped, never continued by whichever one answers to it now.
465
700
  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'
701
+ const reason =
702
+ latest.status !== 'completed'
703
+ ? `round ${latest.round} is still ${latest.status}`
704
+ : nextRound > POLICY.maxRounds
705
+ ? `has used its ${POLICY.maxRounds} rounds`
706
+ : !consultant || !consultantAvailable(consultant)
707
+ ? 'this consultant is no longer configured or available here'
470
708
  : null;
471
- if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
709
+ if (reason) skipped.push({ job_id: latest.job_id, target: member.target, reason });
472
710
  else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
473
711
  }
474
712
  if (!links.length) {
@@ -480,21 +718,30 @@ export class JobManager {
480
718
  return { group, links, skipped };
481
719
  }
482
720
 
721
+ /**
722
+ * @param {ParsedRequest} req
723
+ * @param {Array<{parent: Round | null, round: number, groupMember?: Round}>} links
724
+ * @param {Seat[]} seats
725
+ * @param {GroupParents | null} [fromGroup]
726
+ */
483
727
  #admitted(req, links, seats, fromGroup = null) {
484
728
  // What expose_from_parent copies again: for a group, what that fan-out
485
729
  // exposed (one manifest, shared by its members); otherwise the parent's.
486
- const parent = links[0].groupMember ?? links[0].parent;
730
+ const first = links[0];
731
+ const parent = first?.groupMember ?? first?.parent;
487
732
  const groupId = id('group');
488
733
  // Copied once for the whole request, before a round is counted or a job
489
734
  // exists, into the group's own directory: every member is then handed the
490
735
  // same bytes (the manifest carries their sha256), and a path that is
491
736
  // missing or a tree that blows the cap is the lead's to fix now rather
492
737
  // than a job that fails having already spent a round of this chain.
738
+ /** @type {ExposeManifest | null} */
493
739
  let exposeManifest = null;
494
- const parentEntries = req.context.expose_from_parent ? parent.brief?.expose_paths?.entries ?? [] : [];
740
+ /** @type {BriefExposeEntry[]} */
741
+ const parentEntries = req.context.expose_from_parent ? (parent?.brief?.expose_paths?.entries ?? []) : [];
495
742
  if (req.context.expose_from_parent && parentEntries.length === 0) {
496
743
  throw new RequestError(
497
- `context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
744
+ `context.expose_from_parent: job "${parent?.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
498
745
  'nothing_to_inherit',
499
746
  );
500
747
  }
@@ -503,8 +750,11 @@ export class JobManager {
503
750
  try {
504
751
  fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
505
752
  const plan = planInheritance(parentEntries, req.context.expose_paths);
506
- exposeManifest = materializeExposedPaths(snapshot, plan.paths)
507
- ?? { entries: [], totals: { files: 0, bytes: 0 }, skipped: [] };
753
+ exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
754
+ entries: [],
755
+ totals: { files: 0, bytes: 0 },
756
+ skipped: [],
757
+ };
508
758
  exposeManifest.skipped.push(...plan.skipped);
509
759
  const parentSources = new Set(parentEntries.map((e) => e.source));
510
760
  for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
@@ -515,37 +765,53 @@ export class JobManager {
515
765
  if (req.context.expose_diff) {
516
766
  exposeManifest.skipped.push({ source: d.source, reason: 'replaced', exposed_as: d.exposed_as });
517
767
  } else {
518
- if (reexposeChange(snapshot, d, exposeManifest)) exposeManifest.entries.at(-1).inherited = true;
768
+ if (reexposeChange(snapshot, d, exposeManifest)) {
769
+ const added = exposeManifest.entries.at(-1);
770
+ if (added) added.inherited = true;
771
+ }
519
772
  }
520
773
  }
521
774
  if (req.context.expose_diff) {
522
775
  const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
523
776
  const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
524
777
  if (diff) {
525
- req.context.artifacts = [...req.context.artifacts,
526
- { name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff }];
778
+ req.context.artifacts = [
779
+ ...req.context.artifacts,
780
+ { name: `git diff ${exposeManifest.entries.at(-1)?.base}`, kind: 'diff', language: 'diff', excerpt: diff },
781
+ ];
527
782
  }
528
783
  }
529
784
  markSinceParent(exposeManifest, parentEntries);
530
785
  // Every earlier path gone, and nothing else to go on: refused, as a
531
786
  // first round with no context is.
532
- if (parentEntries.length && !exposeManifest.entries.length && !req.context.facts.length && !req.context.artifacts.length) {
787
+ if (
788
+ parentEntries.length &&
789
+ !exposeManifest.entries.length &&
790
+ !req.context.facts.length &&
791
+ !req.context.artifacts.length
792
+ ) {
533
793
  throw new RequestError(
534
- `context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
794
+ `context.expose_from_parent: nothing job "${parent?.job_id}" exposed is there any more, and this request has no facts or artifacts`,
535
795
  'nothing_to_inherit',
536
796
  );
537
797
  }
538
798
  } catch (err) {
539
799
  store.cleanupJobDir(groupId);
540
- if (err instanceof RequestError || !err.code) throw err;
800
+ if (err instanceof RequestError || !(err instanceof Error) || !('code' in err) || !err.code) throw err;
541
801
  throw new RequestError(err.message, err.code === 'too_large' ? 'expose_paths_too_large' : 'expose_path_invalid');
542
802
  }
543
803
  req._snapshot = path.join(snapshot, 'workspace');
544
804
  }
545
- const members = req.targets.map((target, i) => {
546
- const { parent: from, round } = links[i];
805
+ const members = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
806
+ // One link and one seat per target: start() builds them from req.targets.
807
+ const link = links[i];
808
+ const seat = seats[i];
809
+ if (!link || !seat) throw new Error(`no follow-up link or seat for target "${target}"`);
810
+ const { parent: from, round } = link;
547
811
  const chainId = from ? from.chain_id : id('chain');
548
- const jobId = seats[i].job_id;
812
+ const jobId = seat.job_id;
813
+ const consultant = consultantNamed(target);
814
+ /** @type {Job} */
549
815
  const job = {
550
816
  job_id: jobId,
551
817
  chain_id: chainId,
@@ -582,9 +848,9 @@ export class JobManager {
582
848
  status: 'queued',
583
849
  // What this consultant is: the CLI it runs on, the model and its
584
850
  // lineage, as the operator defined it.
585
- cli: consultantByName(target).cli,
586
- model: consultantByName(target).model,
587
- vendor: consultantByName(target).vendor,
851
+ cli: consultant.cli,
852
+ model: consultant.model,
853
+ vendor: consultant.vendor,
588
854
  created_at: new Date().toISOString(),
589
855
  started_at: null,
590
856
  finished_at: null,
@@ -601,21 +867,28 @@ export class JobManager {
601
867
  this.order.push(jobId);
602
868
  LIVE_HERE.add(jobId);
603
869
  this.#watchCancels();
604
- job._owner = seats[i].owner;
870
+ job._owner = seat.owner;
605
871
  this.#mark(job);
606
872
  return job;
607
873
  });
608
874
  this.#evict();
609
875
 
876
+ const [firstMember] = members;
877
+ if (!firstMember) throw new Error('a consultation needs a target');
878
+ /** @type {Group} */
610
879
  const group = {
611
880
  group_id: groupId,
612
881
  created_at: new Date().toISOString(),
613
882
  mode: req.mode,
614
- question: members[0].question, // already redacted, and byte-identical across the group
615
- job_ids: members.map((m) => m.job_id),
883
+ question: firstMember.question, // already redacted, and byte-identical across the group
884
+ job_ids: members.map((/** @type {Job} */ m) => m.job_id),
616
885
  };
617
886
  this.groups.set(groupId, group);
618
- try { store.persistGroup(group); } catch { /* history is best effort */ }
887
+ try {
888
+ store.persistGroup(group);
889
+ } catch {
890
+ /* history is best effort */
891
+ }
619
892
 
620
893
  // A fan-out is only comparable if every consultant answered the same
621
894
  // question in the same words: every member's brief is the same request,
@@ -634,28 +907,31 @@ export class JobManager {
634
907
  if (req._snapshot) store.cleanupJobDir(groupId);
635
908
 
636
909
  // The members of a follow-up to a group can be at different rounds.
637
- const { round } = members[0];
638
- const sameRound = members.every((m) => m.round === round);
910
+ const { round } = firstMember;
911
+ const sameRound = members.every((/** @type {Job} */ m) => m.round === round);
639
912
  const common = {
640
913
  group_id: groupId,
641
914
  ...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
642
915
  ...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
643
- ...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
916
+ ...(parentEntries.length && exposeManifest ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
644
917
  mode: req.mode,
645
918
  // Counted, not refused: a brief of facts alone is a legitimate
646
919
  // consultation, but one about code that hands over no file is one the
647
920
  // consultant answers without having seen the repository.
648
921
  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
- }),
922
+ ...(exposeManifest?.totals.files
923
+ ? {}
924
+ : {
925
+ files_note:
926
+ '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',
927
+ }),
652
928
  status: 'running',
653
929
  accepted_at: group.created_at,
654
930
  limits: limitsSummary(),
655
931
  };
656
932
 
657
933
  if (!req.fanout) {
658
- const job = members[0];
934
+ const job = firstMember;
659
935
  return {
660
936
  ...common,
661
937
  job_id: job.job_id,
@@ -669,8 +945,12 @@ export class JobManager {
669
945
  return {
670
946
  ...common,
671
947
  question: group.question, // the redacted text every member was actually sent
672
- jobs: members.map((m) => ({
673
- job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model,
948
+ jobs: members.map((/** @type {Job} */ m) => ({
949
+ job_id: m.job_id,
950
+ chain_id: m.chain_id,
951
+ target: m.target,
952
+ cli: m.cli,
953
+ model: m.model,
674
954
  ...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
675
955
  })),
676
956
  poll_with: `consult_get({ group_id: "${groupId}" })`,
@@ -682,25 +962,42 @@ export class JobManager {
682
962
  // (memory wins: it can hold a verdict written since the round was saved).
683
963
  // Each point goes with the lead's verdict on it, so the consultant answers
684
964
  // knowing which of its points were checked and how they came out.
965
+ /**
966
+ * @param {string} chainId
967
+ * @returns {Round[]}
968
+ */
685
969
  #chainRounds(chainId) {
970
+ /** @type {Map<number, Round>} */
686
971
  const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
687
972
  for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
688
973
  return [...byRound.values()];
689
974
  }
690
975
 
976
+ /**
977
+ * @param {string} chainId
978
+ * @returns {PriorRound[]}
979
+ */
691
980
  #chainHistory(chainId) {
692
981
  return this.#chainRounds(chainId)
693
- .filter((j) => j.status === 'completed' && j.result)
982
+ .filter(/** @returns {j is Round & {result: ConsultResult}} */ (j) => j.status === 'completed' && Boolean(j.result))
694
983
  .sort((a, b) => a.round - b.round)
695
984
  .map((j) => {
696
985
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
697
986
  // The checked points first, then the unchecked ones, each with the
698
987
  // 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 }));
703
- const checked = (x) => x.verdict !== null;
988
+ /**
989
+ * @template {ResultItem} T
990
+ * @param {T[]} items
991
+ * @param {(x: T) => {point: string, grounds?: string | null}} shape
992
+ */
993
+ const ordered = (items, shape) =>
994
+ [...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
995
+ id: x.id,
996
+ ...shape(x),
997
+ verdict: verdicts.get(x.id)?.verdict ?? null,
998
+ effect: verdicts.get(x.id)?.effect ?? null,
999
+ }));
1000
+ const checked = (/** @type {{verdict: unknown}} */ x) => x.verdict !== null;
704
1001
  const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
705
1002
  return {
706
1003
  round: j.round,
@@ -718,6 +1015,7 @@ export class JobManager {
718
1015
  #evict() {
719
1016
  while (this.order.length > POLICY.maxJobsRetained) {
720
1017
  const oldest = this.order.shift();
1018
+ if (oldest === undefined) break;
721
1019
  const j = this.jobs.get(oldest);
722
1020
  if (j && (j.status === 'running' || j.status === 'queued')) {
723
1021
  this.order.push(oldest); // never evict live work
@@ -732,6 +1030,7 @@ export class JobManager {
732
1030
  // last member there is nothing left to compare, so drop the group rather than
733
1031
  // keep answering as a complete fan-out with no answers in it (and keep the
734
1032
  // Map, which holds the question text, from growing without bound).
1033
+ /** @param {string} groupId */
735
1034
  #dropGroupIfEmpty(groupId) {
736
1035
  const group = this.groups.get(groupId);
737
1036
  if (!group) return;
@@ -739,8 +1038,15 @@ export class JobManager {
739
1038
  this.groups.delete(groupId);
740
1039
  }
741
1040
 
1041
+ /**
1042
+ * @param {Job} job
1043
+ * @param {ParsedRequest} req
1044
+ * @param {import('./brief.mjs').BriefChain} chain
1045
+ * @param {string} [brief]
1046
+ * @returns {Promise<Job>}
1047
+ */
742
1048
  async #execute(job, req, chain, brief) {
743
- const consultant = consultantByName(job.target);
1049
+ const consultant = consultantNamed(job.target);
744
1050
  const cli = consultant.cli;
745
1051
  const adapter = ADAPTERS[cli];
746
1052
  const workdir = store.makeWorkdir(job.job_id);
@@ -752,7 +1058,7 @@ export class JobManager {
752
1058
  try {
753
1059
  fs.cpSync(req._snapshot, path.join(workdir, 'workspace'), { recursive: true });
754
1060
  } catch (err) {
755
- return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
1061
+ return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err instanceof Error ? err.message : err}`);
756
1062
  }
757
1063
  }
758
1064
  // start() renders the brief and hands it in: every member of a fan-out is
@@ -760,6 +1066,7 @@ export class JobManager {
760
1066
  // added on a follow-up to the group.
761
1067
  const text = brief ?? renderBrief(req, chain);
762
1068
  const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
1069
+ // Left out, not undefined, when the request names none.
763
1070
  const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
764
1071
 
765
1072
  try {
@@ -797,11 +1104,11 @@ export class JobManager {
797
1104
  return this.#fail(
798
1105
  job,
799
1106
  '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.',
1107
+ `no ${t.label} credential to hand the consultant: nothing at ` +
1108
+ `${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a ` +
1109
+ 'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home ' +
1110
+ 'directory that holds the token, or set one of those variables to authenticate with an API key ' +
1111
+ 'instead.',
805
1112
  );
806
1113
  }
807
1114
 
@@ -833,10 +1140,11 @@ export class JobManager {
833
1140
  // What this consultant was actually launched with, read off the
834
1141
  // invocation and the sandbox rather than restated from intent, so the
835
1142
  // 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;
1143
+ job.boundary =
1144
+ adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
1145
+ null;
838
1146
 
839
- const attempt = (timeout) => {
1147
+ const attempt = (/** @type {number} */ timeout) => {
840
1148
  const { handle, done } = runChild({
841
1149
  command: invocation.command,
842
1150
  args: invocation.args,
@@ -844,7 +1152,9 @@ export class JobManager {
844
1152
  env,
845
1153
  input: text,
846
1154
  timeoutMs: timeout,
847
- onCancelSignal: (fn) => job._cancelFns.push(fn),
1155
+ onCancelSignal: (fn) => {
1156
+ job._cancelFns.push(fn);
1157
+ },
848
1158
  compactLine: adapter.compactLine,
849
1159
  settleAfter: adapter.settleAfter,
850
1160
  stopSignal: adapter.stopSignal,
@@ -862,6 +1172,7 @@ export class JobManager {
862
1172
  // Same consultant, same model, same brief: a re-run never picks another
863
1173
  // mind. Every earlier failure is kept in job.retries.
864
1174
  let run = await attempt(budgetMs);
1175
+ /** @type {import('./run.mjs').Interpreted} */
865
1176
  let interpreted;
866
1177
  let authTried = false;
867
1178
  let autoRetries = 0;
@@ -876,7 +1187,9 @@ export class JobManager {
876
1187
  if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
877
1188
  try {
878
1189
  job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
879
- } catch { /* history is best effort */ }
1190
+ } catch {
1191
+ /* history is best effort */
1192
+ }
880
1193
  }
881
1194
  if (run.spawnError) {
882
1195
  return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
@@ -903,7 +1216,7 @@ export class JobManager {
903
1216
  interpreted = adapter.interpret({ ...run, lastMessageText });
904
1217
  // An answer after an error the CLI reported is kept only if it parses;
905
1218
  // otherwise it is that error, re-run like any other.
906
- const { recoveredError } = interpreted;
1219
+ const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
907
1220
  if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
908
1221
  interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
909
1222
  }
@@ -911,9 +1224,12 @@ export class JobManager {
911
1224
  // No envelope at all: what stderr holds is the dump from stopping it,
912
1225
  // not a message to classify. Not re-run, as it may have spent its quota.
913
1226
  if (run.stalled && !interpreted.usageRaw) {
914
- return this.#fail(job, 'cli_error',
1227
+ return this.#fail(
1228
+ job,
1229
+ 'cli_error',
915
1230
  `${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
916
- adapter.progress?.(run) ?? null);
1231
+ adapter.progress?.(run) ?? null,
1232
+ );
917
1233
  }
918
1234
 
919
1235
  const remaining = budgetMs - (Date.now() - t0);
@@ -921,19 +1237,35 @@ export class JobManager {
921
1237
  authTried = true;
922
1238
  const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
923
1239
  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) });
1240
+ job.retries.push({
1241
+ failure_kind: interpreted.failureKind,
1242
+ reason: 'credentials re-seeded',
1243
+ at: new Date().toISOString(),
1244
+ usage: adapter.usageRecord(interpreted.usageRaw),
1245
+ });
925
1246
  run = await attempt(remaining);
926
1247
  continue;
927
1248
  }
928
1249
  }
929
1250
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
930
- if (AUTO_RETRY_KINDS.includes(interpreted.failureKind) && autoRetries < POLICY.maxAutoRetries
931
- && remaining - wait >= MIN_RETRY_MS) {
1251
+ if (
1252
+ AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
1253
+ autoRetries < POLICY.maxAutoRetries &&
1254
+ remaining - wait >= MIN_RETRY_MS
1255
+ ) {
932
1256
  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) });
934
- await new Promise((resolve) => {
1257
+ job.retries.push({
1258
+ failure_kind: interpreted.failureKind,
1259
+ reason: `transient; re-run after ${wait} ms`,
1260
+ at: new Date().toISOString(),
1261
+ usage: adapter.usageRecord(interpreted.usageRaw),
1262
+ });
1263
+ await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
935
1264
  const timer = setTimeout(resolve, wait);
936
- job._cancelFns.push(() => { clearTimeout(timer); resolve(); });
1265
+ job._cancelFns.push(() => {
1266
+ clearTimeout(timer);
1267
+ resolve();
1268
+ });
937
1269
  });
938
1270
  if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
939
1271
  run = await attempt(budgetMs - (Date.now() - t0));
@@ -943,8 +1275,8 @@ export class JobManager {
943
1275
  }
944
1276
 
945
1277
  // 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`;
1278
+ const leftover =
1279
+ run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
948
1280
  if (!interpreted.ok) {
949
1281
  job.usage = adapter.usageRecord(interpreted.usageRaw);
950
1282
  if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
@@ -983,8 +1315,12 @@ export class JobManager {
983
1315
  const notes = [
984
1316
  adviceCaveat(normalized),
985
1317
  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)',
1318
+ interpreted.recoveredError &&
1319
+ `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
1320
+ leftover &&
1321
+ '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)',
1322
+ req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
1323
+ refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS),
988
1324
  ].filter(Boolean);
989
1325
  // No `advice_usable` verdict here. It was always true, so it carried no
990
1326
  // information -- and worse, it read as the server vouching for advice it
@@ -1007,6 +1343,13 @@ export class JobManager {
1007
1343
  }
1008
1344
  }
1009
1345
 
1346
+ /**
1347
+ * @param {Job} job
1348
+ * @param {string} kind
1349
+ * @param {unknown} message
1350
+ * @param {string | null} [detail]
1351
+ * @returns {Job}
1352
+ */
1010
1353
  #fail(job, kind, message, detail) {
1011
1354
  if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
1012
1355
  job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
@@ -1023,12 +1366,22 @@ export class JobManager {
1023
1366
  }
1024
1367
 
1025
1368
  // The in-progress marker other processes read (see store.writeRunning).
1369
+ /** @param {Job} job */
1026
1370
  #mark(job) {
1027
- try { store.writeRunning({ ...this.#record(job), owner: job._owner }); } catch { /* history is best effort */ }
1371
+ try {
1372
+ store.writeRunning({ ...this.#record(job), owner: job._owner });
1373
+ } catch {
1374
+ /* history is best effort */
1375
+ }
1028
1376
  }
1029
1377
 
1378
+ /** @param {Job} job */
1030
1379
  #finish(job) {
1031
- try { store.persistRound(this.#record(job)); } catch { /* history is best effort */ }
1380
+ try {
1381
+ store.persistRound(this.#record(job));
1382
+ } catch {
1383
+ /* history is best effort */
1384
+ }
1032
1385
  store.removeRunning(job.job_id);
1033
1386
  store.releaseSeat(job.job_id);
1034
1387
  LIVE_HERE.delete(job.job_id);
@@ -1043,16 +1396,17 @@ export class JobManager {
1043
1396
  // consultant did not produce and a word outside VERDICTS, then stores what
1044
1397
  // was written. It never decides that a finding was adopted, and it never
1045
1398
  // fills in a verdict the lead did not write.
1399
+ /** @param {RawRecord} [input] */
1046
1400
  record(input = {}) {
1047
1401
  if (input.records !== undefined) return this.#recordMany(input);
1048
1402
  const allowed = ['job_id', 'entries', 'reflection'];
1049
1403
  const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
1050
1404
  if (unknown.length) {
1051
1405
  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
- : ''),
1406
+ `${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records` +
1407
+ (unknown.includes('prediction')
1408
+ ? ". 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"
1409
+ : ''),
1056
1410
  'unknown_field',
1057
1411
  );
1058
1412
  }
@@ -1066,24 +1420,32 @@ export class JobManager {
1066
1420
  // record is checked before anything is written, so a bad id leaves the
1067
1421
  // history as it was; then each job is written on its own, and a write that
1068
1422
  // fails is named in the answer rather than reported as saved.
1423
+ /** @param {RawRecord} input */
1069
1424
  #recordMany(input) {
1070
1425
  const extra = Object.keys(input).filter((k) => k !== 'records');
1071
1426
  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');
1427
+ throw new RequestError(
1428
+ `records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`,
1429
+ 'unknown_field',
1430
+ );
1073
1431
  }
1074
1432
  const { records } = input;
1075
1433
  if (!Array.isArray(records) || records.length === 0) {
1076
1434
  throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
1077
1435
  }
1436
+ /** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
1078
1437
  const problems = [];
1438
+ /** @type {RecordChange[]} */
1079
1439
  const changes = [];
1080
1440
  const seen = new Set();
1081
- records.forEach((r, i) => {
1441
+ records.forEach((/** @type {RawRecord} */ r, i) => {
1082
1442
  try {
1083
- if (seen.has(r?.job_id)) throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
1443
+ if (seen.has(r?.job_id))
1444
+ throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
1084
1445
  seen.add(r?.job_id);
1085
1446
  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');
1447
+ if (unknown.length)
1448
+ throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
1087
1449
  changes.push(this.#checkRecord(r ?? {}));
1088
1450
  } catch (err) {
1089
1451
  if (!(err instanceof RequestError)) throw err;
@@ -1104,9 +1466,16 @@ export class JobManager {
1104
1466
 
1105
1467
  // Everything that can be wrong with a record, found before anything is
1106
1468
  // written: what the job's record and reflection become, or a RequestError.
1469
+ /**
1470
+ * @param {RawRecord} rec
1471
+ * @returns {RecordChange}
1472
+ */
1107
1473
  #checkRecord({ job_id: jobId, entries, reflection }) {
1108
1474
  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');
1475
+ throw new RequestError(
1476
+ 'pass entries (verdicts on specific points), reflection (what the answer added), or both',
1477
+ 'nothing_to_record',
1478
+ );
1110
1479
  }
1111
1480
  // A job this process ran is in memory; one from an earlier session is read
1112
1481
  // back from its round file, so a verdict can still be written once the
@@ -1114,10 +1483,7 @@ export class JobManager {
1114
1483
  const live = this.jobs.get(jobId);
1115
1484
  const job = live ?? store.loadRound(jobId);
1116
1485
  if (!job) {
1117
- throw new RequestError(
1118
- `no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
1119
- 'unknown_job',
1120
- );
1486
+ throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
1121
1487
  }
1122
1488
  if (!job.result) {
1123
1489
  throw new RequestError(
@@ -1131,7 +1497,8 @@ export class JobManager {
1131
1497
 
1132
1498
  const ids = recordableIds(job.result);
1133
1499
  const now = new Date().toISOString();
1134
- const merged = new Map((job.record?.entries ?? []).map((e) => [e.id, e]));
1500
+ /** @type {Map<string, RecordEntry>} */
1501
+ const written = new Map();
1135
1502
 
1136
1503
  for (const entry of entries ?? []) {
1137
1504
  const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
@@ -1148,7 +1515,7 @@ export class JobManager {
1148
1515
  'invalid_verdict',
1149
1516
  );
1150
1517
  }
1151
- merged.set(id, {
1518
+ written.set(id, {
1152
1519
  id,
1153
1520
  verdict,
1154
1521
  effect: text(entry?.effect),
@@ -1162,6 +1529,7 @@ export class JobManager {
1162
1529
  // lead predicted can still arrive with the evidence that settles it, and a
1163
1530
  // surprise can still be wrong, so a match/miss verdict would rate the
1164
1531
  // consultation on the wrong axis.
1532
+ /** @type {Reflection | undefined} */
1165
1533
  let newReflection;
1166
1534
  if (reflection !== undefined) {
1167
1535
  const delta = text(reflection?.delta);
@@ -1180,29 +1548,40 @@ export class JobManager {
1180
1548
  newReflection = { delta, related_item_ids: related, recorded_at: now };
1181
1549
  }
1182
1550
 
1183
- const newRecord = entries === undefined
1184
- ? undefined
1185
- : { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
1186
- return { job, live, record: newRecord, reflection: newReflection };
1551
+ return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
1187
1552
  }
1188
1553
 
1189
- #saveRecord({ job, live, record, reflection }) {
1554
+ /** @param {RecordChange} change */
1555
+ #saveRecord({ job, live, entries, reflection }) {
1190
1556
  const was = { record: job.record, reflection: job.reflection };
1191
- if (reflection !== undefined) job.reflection = reflection;
1192
- if (record !== undefined) job.record = record;
1557
+ // Another server may have written verdicts or a reflection to this round
1558
+ // since this one loaded it. Start from the file, so this call replaces
1559
+ // only the ids it names (and the reflection, if it passes one) and keeps
1560
+ // the rest; the merge goes back into memory too, so consult_get and a
1561
+ // follow-up's brief show the same record.
1562
+ // ponytail: read-then-write, not locked; two writes in the same instant can still lose one.
1563
+ const disk = store.loadRoundAt(job);
1564
+ const base = disk?.result ? disk : job;
1565
+ job.reflection = reflection ?? base.reflection;
1566
+ if (entries !== undefined) {
1567
+ const merged = new Map((base.record?.entries ?? []).map((e) => [e.id, e]));
1568
+ for (const e of entries) merged.set(e.id, e);
1569
+ const ids = job.result ? recordableIds(job.result) : [];
1570
+ job.record = { updated_at: new Date().toISOString(), entries: ids.flatMap((id) => merged.get(id) ?? []) };
1571
+ } else if (base.record) job.record = base.record;
1193
1572
  // An update to a round that already happened, so the index line stays as it
1194
1573
  // was: the history has one line per consultation, not one per edit.
1195
1574
  // Unlike the round itself, a verdict exists nowhere else once this server
1196
1575
  // exits, so a failed write is the lead's to know about, not best effort.
1197
1576
  try {
1198
- store.persistRound(live ? this.#record(job) : job, { appendIndex: false });
1577
+ store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
1199
1578
  } catch (e) {
1200
1579
  // Not saved, so not shown as recorded either.
1201
1580
  Object.assign(job, was);
1202
1581
  return {
1203
1582
  job_id: job.job_id,
1204
1583
  error: 'record_not_saved',
1205
- message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
1584
+ message: `the verdict for "${job.job_id}" could not be written to the history (${e instanceof Error ? e.message : e}); record it again`,
1206
1585
  };
1207
1586
  }
1208
1587
  // Counts only: the entries are what the lead just wrote, and
@@ -1217,6 +1596,12 @@ export class JobManager {
1217
1596
  };
1218
1597
  }
1219
1598
 
1599
+ /**
1600
+ * @param {string} jobId
1601
+ * @param {number | undefined} waitMs
1602
+ * @param {{detail?: string | undefined}} [opts]
1603
+ * @param {AbortSignal} [signal]
1604
+ */
1220
1605
  async wait(jobId, waitMs, opts, signal) {
1221
1606
  const job = this.jobs.get(jobId);
1222
1607
  if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
@@ -1230,6 +1615,13 @@ export class JobManager {
1230
1615
 
1231
1616
  // A consultation another process runs has no promise here, so its history
1232
1617
  // is re-read once a second until it is no longer running or the wait is up.
1618
+ /**
1619
+ * @template {{status: string}} T
1620
+ * @param {() => T | null} read
1621
+ * @param {number | undefined} waitMs
1622
+ * @param {AbortSignal} [signal]
1623
+ * @returns {Promise<T | null>}
1624
+ */
1233
1625
  async #waitOnDisk(read, waitMs, signal) {
1234
1626
  const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1235
1627
  let view = read();
@@ -1254,12 +1646,14 @@ export class JobManager {
1254
1646
  }, CANCEL_POLL_MS).unref();
1255
1647
  }
1256
1648
 
1649
+ /** @param {string} jobId */
1257
1650
  cancel(jobId) {
1258
1651
  const job = this.jobs.get(jobId);
1259
1652
  if (!job) {
1260
1653
  const view = this.view(jobId);
1261
1654
  if (!view) return null;
1262
- if (view.status !== 'running' && view.status !== 'queued') return { ...view, cancel_effect: `job was already ${view.status}` };
1655
+ if (view.status !== 'running' && view.status !== 'queued')
1656
+ return { ...view, cancel_effect: `job was already ${view.status}` };
1263
1657
  return {
1264
1658
  ...view,
1265
1659
  cancel_effect: store.requestCancel(jobId)
@@ -1268,17 +1662,22 @@ export class JobManager {
1268
1662
  };
1269
1663
  }
1270
1664
  if (job.status !== 'running' && job.status !== 'queued') {
1271
- return { ...this.view(jobId), cancel_effect: `job was already ${job.status}` };
1665
+ return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
1272
1666
  }
1273
1667
  job.cancelRequested = true;
1274
1668
  for (const fn of job._cancelFns) fn();
1275
1669
  if (job._handle) job._handle.stop();
1276
- return { ...this.view(jobId), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
1670
+ return { ...this.#jobView(job, undefined), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
1277
1671
  }
1278
1672
 
1279
1673
  // Every field here is derived from the one `members` array it is handed, so a
1280
1674
  // group view can never report one status in `members` and another in
1281
1675
  // `comparison` or `status` for the same job.
1676
+ /**
1677
+ * @param {Group} group
1678
+ * @param {JobView[]} members
1679
+ * @param {{detail?: string | undefined}} [opts]
1680
+ */
1282
1681
  #shapeGroup(group, members, { detail } = {}) {
1283
1682
  const full = detail === 'full';
1284
1683
  const cancelling = members.some((m) => m.status === 'cancelling');
@@ -1295,9 +1694,10 @@ export class JobManager {
1295
1694
  // same thing three times. Hoist one copy to the group and strip it from
1296
1695
  // the members, which keeps the payload readable without losing anything.
1297
1696
  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;
1697
+ const groupBrief =
1698
+ firstBrief && !full
1699
+ ? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
1700
+ : firstBrief;
1301
1701
  // The limits are the server's, not a member's: one copy, on the group.
1302
1702
  // By default a member is only its state: what there is to compare is in
1303
1703
  // `comparison`, once, and the full answer is one consult_get({ job_id }) away.
@@ -1305,7 +1705,8 @@ export class JobManager {
1305
1705
  if (full) return rest;
1306
1706
  const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
1307
1707
  const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
1308
- const cutShort = progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
1708
+ const cutShort =
1709
+ progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
1309
1710
  return {
1310
1711
  job_id: rest.job_id,
1311
1712
  target: rest.target,
@@ -1321,19 +1722,22 @@ export class JobManager {
1321
1722
  const question = full ? group.question : cut(group.question, QUESTION_CHARS);
1322
1723
  const view = {
1323
1724
  group_id: group.group_id,
1324
- status: cancelling ? 'cancelling' : (live ? 'running' : 'done'),
1725
+ status: cancelling ? 'cancelling' : live ? 'running' : 'done',
1325
1726
  mode: group.mode,
1326
1727
  question,
1327
- ...(question !== group.question ? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` } : {}),
1728
+ ...(question !== group.question
1729
+ ? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` }
1730
+ : {}),
1328
1731
  brief: groupBrief,
1329
1732
  members_expected: group.job_ids.length,
1330
1733
  members_available: members.length,
1331
1734
  // History is capped, so an older member can already be gone. Say so:
1332
1735
  // a fan-out that quietly reports fewer answers than it asked for is
1333
1736
  // 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,
1737
+ incomplete_note:
1738
+ missing > 0
1739
+ ? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
1740
+ : null,
1337
1741
  members: trimmed,
1338
1742
  comparison: null,
1339
1743
  cancellable: live,
@@ -1353,16 +1757,29 @@ export class JobManager {
1353
1757
  // While no one has finished there is nothing to set side by side, and an
1354
1758
  // empty column per consultant is only context spent on every poll.
1355
1759
  const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
1356
- if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
1357
- return view;
1760
+ return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
1358
1761
  }
1359
1762
 
1763
+ /**
1764
+ * @param {string} groupId
1765
+ * @param {{detail?: string | undefined}} [opts]
1766
+ */
1360
1767
  groupView(groupId, opts) {
1361
1768
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
1362
1769
  if (!group) return null;
1363
- return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
1770
+ return this.#shapeGroup(
1771
+ group,
1772
+ group.job_ids.map((jid) => this.view(jid, opts)).filter((v) => v != null),
1773
+ opts,
1774
+ );
1364
1775
  }
1365
1776
 
1777
+ /**
1778
+ * @param {string} groupId
1779
+ * @param {number | undefined} waitMs
1780
+ * @param {{detail?: string | undefined}} [opts]
1781
+ * @param {AbortSignal} [signal]
1782
+ */
1366
1783
  async waitGroup(groupId, waitMs, opts, signal) {
1367
1784
  const group = this.groups.get(groupId);
1368
1785
  // A fan-out from another process or an earlier session: nothing here to
@@ -1376,6 +1793,7 @@ export class JobManager {
1376
1793
  return this.groupView(groupId, opts);
1377
1794
  }
1378
1795
 
1796
+ /** @param {string} groupId */
1379
1797
  cancelGroup(groupId) {
1380
1798
  const group = this.groups.get(groupId);
1381
1799
  if (!group) {
@@ -1383,10 +1801,14 @@ export class JobManager {
1383
1801
  if (!view) return null;
1384
1802
  // Started by another server: each member still running there is asked to stop.
1385
1803
  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' };
1804
+ .map((jid) => this.cancel(jid))
1805
+ .filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
1806
+ return {
1807
+ ...view,
1808
+ cancel_effect: asked
1809
+ ? `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`
1810
+ : 'nothing to signal: no consultant in this fan-out is still running',
1811
+ };
1390
1812
  }
1391
1813
  // Count what is actually about to be signalled before signalling it:
1392
1814
  // cancel() short-circuits on a member that has already finished, so a
@@ -1398,15 +1820,16 @@ export class JobManager {
1398
1820
  // Shape the view from the cancel() results, not from a fresh read: the
1399
1821
  // children exit asynchronously, so a signalled member reads as
1400
1822
  // "cancelling" until it is reaped, and the whole payload must say so.
1401
- const members = group.job_ids.map((jid) => this.cancel(jid)).filter(Boolean);
1823
+ const members = group.job_ids.map((jid) => this.cancel(jid)).filter((v) => v != null);
1402
1824
  const total = group.job_ids.length;
1403
1825
  return {
1404
1826
  ...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`,
1827
+ cancel_effect:
1828
+ signalled === 0
1829
+ ? 'nothing to signal: every consultant in this fan-out had already finished'
1830
+ : signalled === total
1831
+ ? 'every consultant process group in this fan-out was signalled'
1832
+ : `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
1410
1833
  };
1411
1834
  }
1412
1835
 
@@ -1422,7 +1845,10 @@ export class JobManager {
1422
1845
  for (const job of live) {
1423
1846
  job.cancelRequested = true;
1424
1847
  for (const fn of job._cancelFns) fn();
1425
- if (job._handle) { handles.push(job._handle); job._handle.stop(); }
1848
+ if (job._handle) {
1849
+ handles.push(job._handle);
1850
+ job._handle.stop();
1851
+ }
1426
1852
  }
1427
1853
  const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
1428
1854
  await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
@@ -1452,8 +1878,13 @@ export class JobManager {
1452
1878
  // files of that diff are the same entry for this purpose.
1453
1879
  // ponytail: a diff recorded before 1.11 hashed its files in git's order, not
1454
1880
  // path order, so its re-copy can read as changed when it is not.
1881
+ /**
1882
+ * @param {ExposeManifest} manifest
1883
+ * @param {BriefExposeEntry[]} parentEntries
1884
+ */
1455
1885
  function markSinceParent(manifest, parentEntries) {
1456
- const key = (source, kind) => `${kind === 'change_files' ? 'diff' : kind}\0${source}`;
1886
+ const key = (/** @type {string} */ source, /** @type {string} */ kind) =>
1887
+ `${kind === 'change_files' ? 'diff' : kind}\0${source}`;
1457
1888
  const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
1458
1889
  for (const e of manifest.entries) {
1459
1890
  const k = key(e.source, e.kind);
@@ -1464,13 +1895,13 @@ function markSinceParent(manifest, parentEntries) {
1464
1895
  // What consult_start says a follow-up copied again: the entries it shares
1465
1896
  // with the parent round, the ones left out and why, and how many files that
1466
1897
  // added to this round.
1898
+ /** @param {ExposeManifest} manifest */
1467
1899
  function inheritedSummary(manifest) {
1468
1900
  const carried = manifest.entries.filter((e) => e.inherited);
1469
1901
  return {
1470
1902
  entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
1471
1903
  // 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 })),
1904
+ left_out: manifest.skipped.filter((x) => x.exposed_as).map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
1474
1905
  files: carried.reduce((n, e) => n + e.files, 0),
1475
1906
  };
1476
1907
  }
@@ -1480,6 +1911,10 @@ function inheritedSummary(manifest) {
1480
1911
  // is the raw output stream, unparsed: material the lead may read, never an
1481
1912
  // answer, which is why it sits beside the failure and not in `result`.
1482
1913
  const PARTIAL_TAIL_CHARS = 8_000;
1914
+ /**
1915
+ * @param {import('./run.mjs').ChildResult} run
1916
+ * @returns {import('./store.mjs').PartialOutput | null}
1917
+ */
1483
1918
  function partialOutput(run) {
1484
1919
  const out = String(run.stdout ?? '');
1485
1920
  if (!out.trim()) return null;
@@ -1497,10 +1932,16 @@ function partialOutput(run) {
1497
1932
  // Waits for `promise` (null: for nothing), at most `ms`, and no longer than
1498
1933
  // `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
1499
1934
  // cut short leaves nothing behind.
1935
+ /**
1936
+ * @param {Promise<unknown> | null | undefined} promise
1937
+ * @param {number} ms
1938
+ * @param {AbortSignal} [signal]
1939
+ */
1500
1940
  async function settled(promise, ms, signal) {
1501
1941
  let timer;
1942
+ /** @type {(() => void) | undefined} */
1502
1943
  let stop;
1503
- const until = new Promise((resolve) => {
1944
+ const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
1504
1945
  stop = resolve;
1505
1946
  timer = setTimeout(resolve, ms);
1506
1947
  if (signal?.aborted) resolve();
@@ -1510,10 +1951,11 @@ async function settled(promise, ms, signal) {
1510
1951
  await (promise ? Promise.race([promise, until]) : until);
1511
1952
  } finally {
1512
1953
  clearTimeout(timer);
1513
- signal?.removeEventListener('abort', stop);
1954
+ if (stop) signal?.removeEventListener('abort', stop);
1514
1955
  }
1515
1956
  }
1516
1957
 
1958
+ /** @param {string | Record<string, unknown>} text */
1517
1959
  function parses(text) {
1518
1960
  try {
1519
1961
  normalizeResult(text);
@@ -1524,6 +1966,10 @@ function parses(text) {
1524
1966
  }
1525
1967
  }
1526
1968
 
1969
+ /**
1970
+ * @param {ConsultResult} result
1971
+ * @returns {string[]}
1972
+ */
1527
1973
  function recordableIds(result) {
1528
1974
  return [
1529
1975
  ...result.findings.map((f) => f.id),
@@ -1534,7 +1980,12 @@ function recordableIds(result) {
1534
1980
 
1535
1981
  // How many of each word, in the order they first appear. The only arithmetic
1536
1982
  // this server does on anyone's judgement.
1983
+ /**
1984
+ * @param {Iterable<string>} values
1985
+ * @returns {Record<string, number>}
1986
+ */
1537
1987
  function tally(values) {
1988
+ /** @type {Record<string, number>} */
1538
1989
  const out = {};
1539
1990
  for (const v of values) out[v] = (out[v] ?? 0) + 1;
1540
1991
  return out;
@@ -1543,17 +1994,27 @@ function tally(values) {
1543
1994
  // Counting is mechanical and stays that way: how many points the answer has,
1544
1995
  // how many carry a verdict, which ones do not. No judgement about whether the
1545
1996
  // consultation was worth it -- that is the lead's to draw from the entries.
1997
+ /**
1998
+ * @param {Round} job
1999
+ * @returns {JobRecord}
2000
+ */
1546
2001
  function recordSummary(job) {
1547
- const ids = recordableIds(job.result);
1548
- const written = new Set(job.record.entries.map((e) => e.id));
2002
+ // Callers pass a round that has a record, and a record is only written against an answer (#checkRecord).
2003
+ const record = /** @type {JobRecord} */ (job.record);
2004
+ const ids = recordableIds(/** @type {ConsultResult} */ (job.result));
2005
+ const written = new Set(record.entries.map((e) => e.id));
1549
2006
  return {
1550
- ...job.record,
1551
- verdicts: tally(job.record.entries.map((e) => e.verdict)),
2007
+ ...record,
2008
+ verdicts: tally(record.entries.map((e) => e.verdict)),
1552
2009
  coverage: { recordable: ids.length, recorded: written.size },
1553
2010
  unrecorded: ids.filter((id) => !written.has(id)),
1554
2011
  };
1555
2012
  }
1556
2013
 
2014
+ /**
2015
+ * @param {unknown} value
2016
+ * @returns {string | null}
2017
+ */
1557
2018
  function text(value) {
1558
2019
  if (typeof value !== 'string') return null;
1559
2020
  const t = redact(value).trim();
@@ -1566,24 +2027,37 @@ function text(value) {
1566
2027
  // a key left out is inherited -- one passed, even as an empty list, replaces
1567
2028
  // it -- and the validation that follows sees the result. Files are not:
1568
2029
  // copying them again stays expose_from_parent, named in the call (schema.mjs).
2030
+ /**
2031
+ * @param {RawRequest} raw
2032
+ * @param {Round} round
2033
+ * @returns {RawRequest}
2034
+ */
1569
2035
  function inherit(raw, round) {
1570
2036
  const { brief } = round;
1571
2037
  // A round kept off the web stays off: the material it carries over is the same.
2038
+ /** @type {RawRequest} */
1572
2039
  const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1573
2040
  if (!brief) return out;
1574
- 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];
2041
+ /** @type {Array<'objective' | 'success_criteria' | 'constraints'>} */
2042
+ const topKeys = ['objective', 'success_criteria', 'constraints'];
2043
+ for (const k of topKeys) {
2044
+ const v = brief[k];
2045
+ if (out[k] === undefined && raw.context?.[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) out[k] = v;
1576
2046
  }
1577
2047
  const ctx = { ...(raw.context ?? {}) };
1578
- for (const k of ['facts', 'proposal', 'counterpoints']) {
1579
- if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
2048
+ /** @type {Array<'facts' | 'proposal' | 'counterpoints'>} */
2049
+ const contextKeys = ['facts', 'proposal', 'counterpoints'];
2050
+ for (const k of contextKeys) {
2051
+ const v = brief[k];
2052
+ if (ctx[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) ctx[k] = v;
1580
2053
  }
1581
2054
  // The diff expose_diff added is not the lead's material: it is taken again
1582
2055
  // (or replaced) through expose_from_parent, against the files as they are now.
1583
2056
  // It is the last artifact (appended after the lead's own), so only that one is dropped.
1584
2057
  const artifacts = [...(brief.artifacts ?? [])];
1585
2058
  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();
2059
+ if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
2060
+ artifacts.pop();
1587
2061
  if (ctx.artifacts === undefined && artifacts.length) {
1588
2062
  // Stored with null for an absent language or source; the request schema takes them as optional strings.
1589
2063
  ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
@@ -1592,6 +2066,7 @@ function inherit(raw, round) {
1592
2066
  return out;
1593
2067
  }
1594
2068
 
2069
+ /** @param {ReturnType<typeof normalizeResult>} normalized */
1595
2070
  function adviceCaveat(normalized) {
1596
2071
  const { result, quality } = normalized;
1597
2072
  const notes = [];
@@ -1603,16 +2078,51 @@ function adviceCaveat(normalized) {
1603
2078
  notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
1604
2079
  }
1605
2080
  // 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');
2081
+ if (!result.stance)
2082
+ notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
1607
2083
  if (quality.findings_without_grounds > 0) {
1608
2084
  notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
1609
2085
  }
1610
2086
  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');
2087
+ notes.push("no sources were cited; the findings rest on the consultant's own reasoning over the brief");
1612
2088
  }
1613
2089
  return notes.length ? notes.join('; ') : null;
1614
2090
  }
1615
2091
 
2092
+ // Files were handed over, but no call to a file-reading tool succeeded. On
2093
+ // 2026-10-20 an agy release renamed its read tool, every read was refused,
2094
+ // and the answer still cited line numbers. Only for a CLI that reports its
2095
+ // tool calls and names its reading tools; the others say nothing here.
2096
+ /**
2097
+ * @param {Record<string, number> | null | undefined} calls
2098
+ * @param {readonly string[] | undefined} readTools
2099
+ * @returns {string | null}
2100
+ */
2101
+ function unreadCaveat(calls, readTools) {
2102
+ if (!calls || !readTools) return null;
2103
+ if (readTools.some((t) => calls[t])) return null;
2104
+ const failed = readTools.reduce((n, t) => n + (calls[`${t}:error`] ?? 0), 0);
2105
+ const how = failed ? `${failed} failed or were refused` : 'none was made';
2106
+ return `files were exposed, but no call to read one succeeded (${how})`;
2107
+ }
2108
+
2109
+ // A refused call to a tool outside the allowlist is most often a tool the CLI
2110
+ // renamed in a new release; naming it lets one consultation show the cause.
2111
+ /**
2112
+ * @param {Record<string, number> | null | undefined} calls
2113
+ * @param {readonly string[] | undefined} allowed
2114
+ * @returns {string | null}
2115
+ */
2116
+ function refusedToolCaveat(calls, allowed) {
2117
+ if (!calls || !allowed) return null;
2118
+ const names = Object.keys(calls)
2119
+ .filter((k) => k.endsWith(':error'))
2120
+ .map((k) => k.slice(0, -':error'.length))
2121
+ .filter((t) => !allowed.includes(t));
2122
+ if (!names.length) return null;
2123
+ return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed a tool`;
2124
+ }
2125
+
1616
2126
  // A lead may legitimately name its own CLI as the consultant: a fresh child
1617
2127
  // session with no shared context is still a useful clean-context re-read.
1618
2128
  // But it is not an independent opinion -- the answer comes from the same
@@ -1620,10 +2130,17 @@ function adviceCaveat(normalized) {
1620
2130
  // lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
1621
2131
  // for a GLM lead and DeepSeek for anyone.
1622
2132
 
2133
+ /**
2134
+ * @param {string | null | undefined} caller
2135
+ * @param {string | null | undefined} callerModel
2136
+ * @param {Consultant} consultant
2137
+ * @returns {string | null}
2138
+ */
1623
2139
  function sameVendorCaveat(caller, callerModel, consultant) {
1624
2140
  // The lead's lineage: its declared model if that names one, else its CLI's
1625
2141
  // 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);
2142
+ const callerCli = CLI_IDS.find((c) => c === caller);
2143
+ const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (callerCli ? (CLI_VENDORS[callerCli] ?? null) : null);
1627
2144
  const vendor = consultant.vendor;
1628
2145
  if (!callerVendor || !vendor || callerVendor !== vendor) return null;
1629
2146
  // Say what a fresh session of the same lineage removes and what it keeps.
@@ -1653,21 +2170,26 @@ function sameVendorCaveat(caller, callerModel, consultant) {
1653
2170
  // A mechanical side-by-side. The server deliberately does not decide whether
1654
2171
  // the consultants agree: inventing agreement that is not there is exactly the
1655
2172
  // failure a second opinion is supposed to prevent.
2173
+ /**
2174
+ * @param {JobView[]} members
2175
+ * @param {number} budget
2176
+ */
1656
2177
  function comparison(members, budget) {
1657
2178
  // Each column first pays for its fixed fields; the rest is its budget.
1658
2179
  const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
1659
- let columns = members.map((m) => byTarget(m, each));
2180
+ const firstPass = members.map((m) => ({ m, column: byTarget(m, each) }));
2181
+ let columns = firstPass.map((x) => x.column);
1660
2182
  // What the short answers did not spend goes to the ones that were cut.
1661
2183
  const cutShort = columns.filter((c) => c.truncated).length;
1662
2184
  if (each !== Infinity && cutShort) {
1663
2185
  const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
1664
- columns = members.map((m, i) => (columns[i].truncated ? byTarget(m, each + spare / cutShort) : columns[i]));
2186
+ columns = firstPass.map(({ m, column }) => (column.truncated ? byTarget(m, each + spare / cutShort) : column));
1665
2187
  }
1666
2188
  return {
1667
2189
  by_target: columns,
1668
2190
  note:
1669
2191
  '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 ' +
2192
+ "Each stance is the consultant's own declaration, relayed as given -- consultants that share a finding can still " +
1671
2193
  'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
1672
2194
  'follow-up only where a divergence would change your decision.',
1673
2195
  };
@@ -1680,17 +2202,32 @@ function comparison(members, budget) {
1680
2202
  // shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
1681
2203
  // `omitted` counts the items a list could not hold, and `full_with` names the
1682
2204
  // call that has everything.
2205
+ /**
2206
+ * @param {JobView} m
2207
+ * @param {number} budget
2208
+ * @returns {ComparisonColumn}
2209
+ */
1683
2210
  function byTarget(m, budget) {
1684
2211
  const full = budget === Infinity;
1685
2212
  let left = budget;
1686
2213
  let truncated = false;
2214
+ /** @type {Record<string, number>} */
1687
2215
  const omitted = {};
1688
- const clip = (s, max) => {
2216
+ const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
1689
2217
  const out = full ? s : cut(s, max);
1690
2218
  if (out !== s) truncated = true;
1691
2219
  return out;
1692
2220
  };
1693
2221
  const item = Math.min(ITEM_CHARS, budget / 10);
2222
+ /**
2223
+ * @template T
2224
+ * @param {string} name
2225
+ * @param {T[] | undefined} items
2226
+ * @param {number} fields
2227
+ * @param {(x: T, n: number) => unknown} build
2228
+ * @param {number} part
2229
+ * @param {number} [floor]
2230
+ */
1694
2231
  const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
1695
2232
  if (full) return items.map((x) => build(x, Infinity));
1696
2233
  let share = left * part;
@@ -1710,25 +2247,46 @@ function byTarget(m, budget) {
1710
2247
  left -= n;
1711
2248
  out.push(v);
1712
2249
  }
1713
- if (out.length < items.length) { omitted[name] = items.length - out.length; truncated = true; }
2250
+ if (out.length < items.length) {
2251
+ omitted[name] = items.length - out.length;
2252
+ truncated = true;
2253
+ }
1714
2254
  return out;
1715
2255
  };
1716
2256
  const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
1717
2257
  const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
1718
2258
  if (!full) left -= printed(summary) + printed(caveat);
2259
+ /** @type {Partial<ConsultResult>} */
1719
2260
  const r = m.result ?? {};
1720
2261
  // The findings first, with up to 60% of what is left and their points cut
1721
2262
  // as short as it takes: they are what gets recorded against, so their ids
1722
2263
  // 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);
2264
+ const findingPoints = list(
2265
+ 'finding_points',
2266
+ r.findings,
2267
+ 1,
2268
+ (f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }),
2269
+ 0.6,
2270
+ 20,
2271
+ );
2272
+ const decisionChangers = list(
2273
+ 'decision_changers',
2274
+ r.decision_changers,
2275
+ 2,
2276
+ (d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }),
2277
+ 1 / 4,
2278
+ );
1727
2279
  const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
1728
2280
  const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
1729
2281
  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);
2282
+ const disagreements = list(
2283
+ 'remaining_disagreements',
2284
+ r.remaining_disagreements,
2285
+ 3,
2286
+ (d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
2287
+ 1,
2288
+ );
2289
+ /** @type {ComparisonColumn} */
1732
2290
  const entry = {
1733
2291
  job_id: m.job_id,
1734
2292
  target: m.target,
@@ -1763,7 +2321,11 @@ function byTarget(m, budget) {
1763
2321
  points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
1764
2322
  };
1765
2323
  // 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; }
2324
+ if (!full) {
2325
+ delete entry.cli;
2326
+ delete entry.status;
2327
+ delete entry.failure_kind;
2328
+ }
1767
2329
  if (Object.keys(omitted).length) entry.omitted = omitted;
1768
2330
  if (truncated) {
1769
2331
  entry.truncated = true;
@@ -1794,6 +2356,10 @@ const MIN_TEXT_CHARS = 40;
1794
2356
  * points being checked, without the rest of the answer. A projection, not a
1795
2357
  * summary. An id the answer does not have is refused with the ones it has.
1796
2358
  */
2359
+ /**
2360
+ * @param {JobView} view
2361
+ * @param {string[]} ids
2362
+ */
1797
2363
  export function pointsOf(view, ids) {
1798
2364
  if (!view.result) {
1799
2365
  throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
@@ -1802,13 +2368,21 @@ export function pointsOf(view, ids) {
1802
2368
  const known = recordableIds(r);
1803
2369
  const unknown = ids.filter((id) => !known.includes(id));
1804
2370
  if (unknown.length) {
1805
- throw new RequestError(`${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`, 'unknown_entry_id');
2371
+ throw new RequestError(
2372
+ `${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`,
2373
+ 'unknown_entry_id',
2374
+ );
1806
2375
  }
1807
2376
  const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
1808
2377
  const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
1809
2378
  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);
1811
- return { kind, ...item, recorded: verdicts.get(id) ?? null };
2379
+ // Every id was checked against recordableIds above, so one list has it.
2380
+ const [point] = Object.entries(lists).flatMap(([kind, l]) => {
2381
+ const item = l.find((x) => x.id === id);
2382
+ return item ? [{ kind, ...item }] : [];
2383
+ });
2384
+ if (!point) throw new RequestError(`"${id}" is not a point in this answer`, 'unknown_entry_id');
2385
+ return { ...point, recorded: verdicts.get(id) ?? null };
1812
2386
  });
1813
2387
  return {
1814
2388
  job_id: view.job_id,
@@ -1837,6 +2411,7 @@ export function pointsOf(view, ids) {
1837
2411
 
1838
2412
  // What a value takes as the server prints it, nested four levels into the
1839
2413
  // response: two-space JSON, every line indented eight more.
2414
+ /** @param {unknown} v */
1840
2415
  function printed(v) {
1841
2416
  const j = JSON.stringify(v, null, 2) ?? '';
1842
2417
  return j.length + 8 * j.split('\n').length;
@@ -1844,10 +2419,15 @@ function printed(v) {
1844
2419
 
1845
2420
  // A text cut to `max` characters as printed in JSON, so a run of characters
1846
2421
  // that JSON escapes (a control character takes six) cannot outgrow the cut.
2422
+ /**
2423
+ * @param {string | null | undefined} s
2424
+ * @param {number} max
2425
+ * @returns {string | null | undefined}
2426
+ */
1847
2427
  function cut(s, max) {
1848
2428
  if (typeof s !== 'string') return s;
1849
2429
  const room = Math.max(0, Math.floor(max));
1850
- const width = (t) => JSON.stringify(t).length - 2;
2430
+ const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
1851
2431
  if (width(s) <= room) return s;
1852
2432
  // One character of the room goes to the ellipsis, and a surrogate pair is
1853
2433
  // never split.
@@ -1861,6 +2441,11 @@ function cut(s, max) {
1861
2441
  // is named rather than repeated: its size and sha256 (of the JSON stored in
1862
2442
  // the round file, so both can be checked against it), where it is kept, and
1863
2443
  // what was copied -- skipped files counted by reason, not listed.
2444
+ /**
2445
+ * @param {FullView} view
2446
+ * @param {string} [detail]
2447
+ * @returns {JobView}
2448
+ */
1864
2449
  function compactBrief(view, detail) {
1865
2450
  if (detail === 'full' || !view.brief) return view;
1866
2451
  const json = JSON.stringify(view.brief);
@@ -1872,12 +2457,18 @@ function compactBrief(view, detail) {
1872
2457
  sha256: crypto.createHash('sha256').update(json).digest('hex'),
1873
2458
  // Under the severally home (~/.severally unless the operator moved it).
1874
2459
  history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
1875
- exposed: ex ? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((x) => x.reason)) } : null,
2460
+ exposed: ex
2461
+ ? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((/** @type {{reason: string}} */ x) => x.reason)) }
2462
+ : null,
1876
2463
  full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
1877
2464
  },
1878
2465
  };
1879
2466
  }
1880
2467
 
2468
+ /**
2469
+ * @param {Round} job
2470
+ * @returns {string}
2471
+ */
1881
2472
  function nextStep(job) {
1882
2473
  if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
1883
2474
  if (job.status === 'completed') {
@@ -1886,27 +2477,35 @@ function nextStep(job) {
1886
2477
  // point showed, not when the answer arrives. Name the open ones until then.
1887
2478
  const written = new Set((job.record?.entries ?? []).map((e) => e.id));
1888
2479
  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;
2480
+ const close =
2481
+ open > 0
2482
+ ? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
2483
+ : '';
2484
+ return (
2485
+ (left > 0
2486
+ ? `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}").`
2487
+ : 'rounds exhausted: decide with what you have.') + close
2488
+ );
1895
2489
  }
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';
2490
+ if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
2491
+ return 'retriable: narrow the brief and start a new consultation';
2492
+ if (job.failure?.kind === 'interrupted')
2493
+ return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
2494
+ if (job.failure?.kind === 'rate_limited')
2495
+ 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';
2496
+ if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth')
2497
+ return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
2498
+ if (job.failure?.kind === 'invalid_output')
2499
+ return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
1901
2500
  return 'proceed on your own judgement and note that no peer input was obtained';
1902
2501
  }
1903
2502
 
1904
2503
  // Every spelling a token could name a flag by: `--name[=v]` as written, and for
1905
2504
  // a single dash both a short flag with its value attached (`-sdanger-full-access`
1906
2505
  // in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
1907
- const flagNames = (a) => {
2506
+ const flagNames = (/** @type {string} */ a) => {
1908
2507
  if (!a.startsWith('-') || a === '-') return [];
1909
- const name = a.split('=')[0];
2508
+ const [name = ''] = a.split('=');
1910
2509
  return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
1911
2510
  };
1912
2511
 
@@ -1917,6 +2516,12 @@ const flagNames = (a) => {
1917
2516
  * adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
1918
2517
  * undo it). Values (tokens without a leading dash) are not inspected.
1919
2518
  */
2519
+ /**
2520
+ * @param {CliId} target
2521
+ * @param {readonly string[]} adapterArgs
2522
+ * @param {unknown} extra
2523
+ * @returns {string | null}
2524
+ */
1920
2525
  export function extraArgsProblem(target, adapterArgs, extra) {
1921
2526
  if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
1922
2527
  const adapter = ADAPTERS[target];
@@ -1930,11 +2535,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
1930
2535
  }
1931
2536
 
1932
2537
  /** Extra args go before a trailing `-` (Codex's read-the-prompt-from-stdin marker), else at the end. */
2538
+ /**
2539
+ * @param {string[]} args
2540
+ * @param {readonly string[]} extra
2541
+ * @returns {string[]}
2542
+ */
1933
2543
  export function withExtraArgs(args, extra) {
1934
2544
  if (extra.length === 0) return args;
1935
2545
  return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
1936
2546
  }
1937
2547
 
2548
+ /**
2549
+ * @param {CliId} target
2550
+ * @param {readonly string[]} args
2551
+ */
1938
2552
  export function assertNoForbiddenFlags(target, args) {
1939
2553
  const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
1940
2554
  const hit = args.find((a) => forbidden.includes(a));