severally-mcp 2.4.0 → 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 (40) hide show
  1. package/README.ja.md +44 -19
  2. package/README.md +44 -18
  3. package/package.json +3 -1
  4. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  5. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  6. package/plugins/severally/dist/severally-mcp.mjs +585 -255
  7. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  8. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  9. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  10. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  11. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  12. package/scripts/build.mjs +57 -54
  13. package/scripts/doctor.mjs +15 -4
  14. package/scripts/install.mjs +73 -9
  15. package/scripts/live-check.mjs +11 -5
  16. package/scripts/live-mcp-check.mjs +10 -4
  17. package/src/adapters/antigravity-sandbox.mjs +24 -6
  18. package/src/adapters/antigravity.mjs +80 -17
  19. package/src/adapters/claude-code.mjs +49 -8
  20. package/src/adapters/codex.mjs +56 -6
  21. package/src/adapters/opencode-sandbox.mjs +20 -2
  22. package/src/adapters/opencode.mjs +115 -19
  23. package/src/brief.mjs +84 -7
  24. package/src/consultants.mjs +184 -25
  25. package/src/cross-spawn-escape.d.ts +7 -0
  26. package/src/export.mjs +50 -6
  27. package/src/expose-paths.mjs +149 -7
  28. package/src/failures.mjs +8 -1
  29. package/src/jobs.mjs +578 -132
  30. package/src/jsonc.mjs +11 -3
  31. package/src/parse-result.mjs +91 -14
  32. package/src/platform.mjs +52 -2
  33. package/src/policy.mjs +151 -28
  34. package/src/redact.mjs +22 -3
  35. package/src/result-schema.mjs +13 -0
  36. package/src/run.mjs +158 -12
  37. package/src/schema.mjs +84 -11
  38. package/src/server.mjs +49 -14
  39. package/src/status.mjs +16 -4
  40. package/src/store.mjs +287 -6
package/src/jobs.mjs CHANGED
@@ -12,9 +12,10 @@ import {
12
12
  detectCaller,
13
13
  configProblem,
14
14
  consultantByName,
15
+ consultantNamed,
15
16
  consultantAvailable,
16
17
  } from './policy.mjs';
17
- import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
18
+ import { vendorOf, sameModel, CLI_VENDORS, CLI_IDS } from './consultants.mjs';
18
19
  import { parseRequest, RequestError, charCount } from './schema.mjs';
19
20
  import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
20
21
  import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
@@ -29,6 +30,118 @@ import * as claudeCode from './adapters/claude-code.mjs';
29
30
  import * as antigravity from './adapters/antigravity.mjs';
30
31
  import * as opencode from './adapters/opencode.mjs';
31
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>} */
32
145
  const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
33
146
 
34
147
  // The recovery respawn runs only while this much of the original budget is
@@ -55,6 +168,7 @@ const API_KEY_VARS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
55
168
  const CREDENTIAL_FILE_VARS = ['GOOGLE_APPLICATION_CREDENTIALS'];
56
169
  const ALL_API_CREDENTIAL_VARS = [...API_KEY_VARS, ...CREDENTIAL_FILE_VARS];
57
170
 
171
+ /** @param {Record<string, string | undefined>} env */
58
172
  function hasApiCredential(env) {
59
173
  if (API_KEY_VARS.some((name) => env[name])) return true;
60
174
  return CREDENTIAL_FILE_VARS.some((name) => env[name] && fs.existsSync(env[name]));
@@ -64,11 +178,12 @@ function hasApiCredential(env) {
64
178
  // listed as not reported rather than silently missing from the totals.
65
179
  const USAGE_FIGURES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens', 'cost_usd'];
66
180
 
67
- const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
181
+ const id = (/** @type {string} */ prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
68
182
 
69
183
  // Jobs this process is running, across every JobManager in it. A marker owned
70
184
  // by this pid but missing here was left by an earlier process that had the
71
185
  // same pid, so it is as dead as one whose pid no longer exists.
186
+ /** @type {Set<string>} */
72
187
  const LIVE_HERE = new Set();
73
188
 
74
189
  // What became of the process that started a consultation: 'alive', 'dead',
@@ -76,6 +191,11 @@ const LIVE_HERE = new Set();
76
191
  // (budget, retries included, plus the kill grace), so either that process is
77
192
  // stuck or the pid now belongs to something else. Nothing is killed on the
78
193
  // strength of this.
194
+ /**
195
+ * @param {string} jobId
196
+ * @param {JobOwner | null | undefined} owner
197
+ * @returns {'alive' | 'dead' | 'overdue'}
198
+ */
79
199
  function ownerState(jobId, owner) {
80
200
  if (!owner) return 'dead';
81
201
  let pidAlive;
@@ -85,7 +205,7 @@ function ownerState(jobId, owner) {
85
205
  process.kill(owner.pid, 0);
86
206
  pidAlive = true;
87
207
  } catch (err) {
88
- pidAlive = err.code === 'EPERM';
208
+ pidAlive = err instanceof Error && 'code' in err && err.code === 'EPERM';
89
209
  }
90
210
  if (!pidAlive) return 'dead';
91
211
  return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
@@ -93,9 +213,13 @@ function ownerState(jobId, owner) {
93
213
 
94
214
  export class JobManager {
95
215
  constructor() {
216
+ /** @type {NodeJS.Timeout | null} */
96
217
  this._cancelWatch = null;
218
+ /** @type {Map<string, Job>} */
97
219
  this.jobs = new Map();
220
+ /** @type {Map<string, Group>} */
98
221
  this.groups = new Map();
222
+ /** @type {string[]} */
99
223
  this.order = [];
100
224
  store.ensureDirs();
101
225
  }
@@ -103,6 +227,10 @@ export class JobManager {
103
227
  // A consultation this process does not hold: finished (its round file), or
104
228
  // still in progress (its marker). A marker whose owner is gone becomes a
105
229
  // failed round, `interrupted`, written once by whichever process claims it.
230
+ /**
231
+ * @param {string} jobId
232
+ * @returns {StoredRound | null}
233
+ */
106
234
  #fromDisk(jobId) {
107
235
  const done = store.loadRound(jobId);
108
236
  if (done) return done;
@@ -117,6 +245,12 @@ export class JobManager {
117
245
  // something the next reader finishes: the round is written (the index
118
246
  // takes it once), then the marker goes. A round already on disk wins -- its
119
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
+ */
120
254
  #interrupt(jobId, rec, state) {
121
255
  const done = store.loadRound(jobId);
122
256
  if (done) {
@@ -151,22 +285,26 @@ export class JobManager {
151
285
  for (const jobId of store.listRunning()) {
152
286
  const rec = store.loadRunning(jobId);
153
287
  const state = rec && ownerState(jobId, rec.owner);
154
- if (rec && state !== 'alive') this.#interrupt(jobId, rec, state);
288
+ if (rec && state && state !== 'alive') this.#interrupt(jobId, rec, state);
155
289
  }
156
290
  }
157
291
 
292
+ /**
293
+ * @param {StoredRound} rec
294
+ * @returns {FullView}
295
+ */
158
296
  #diskView(rec) {
159
- const elsewhere = Boolean(rec.owner);
160
297
  const { owner, ...view } = rec;
161
298
  return {
162
299
  ...view,
163
- cancellable: false,
300
+ // consult_cancel asks the process that owns it to stop it.
301
+ cancellable: Boolean(owner) && (view.status === 'running' || view.status === 'queued'),
164
302
  progress: null,
165
303
  record: view.record ? recordSummary(view) : null,
166
- brief: elsewhere ? null : view.brief,
167
- limits: elsewhere ? null : limitsSummary(),
168
- next_step: elsewhere
169
- ? `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`
170
308
  : nextStep(view),
171
309
  };
172
310
  }
@@ -175,6 +313,10 @@ export class JobManager {
175
313
  return [...this.jobs.values()].filter((j) => j.status === 'running');
176
314
  }
177
315
 
316
+ /**
317
+ * @param {Job} job
318
+ * @returns {Round}
319
+ */
178
320
  #record(job) {
179
321
  return {
180
322
  job_id: job.job_id,
@@ -218,13 +360,27 @@ export class JobManager {
218
360
 
219
361
  // `detail: "full"` returns the brief as stored; by default a finished
220
362
  // consultation names it (size, sha256, round file) instead of repeating it.
221
- /** @param {string} jobId @param {{detail?: string}} [opts] */
363
+ /**
364
+ * @param {string} jobId
365
+ * @param {{detail?: string | undefined}} [opts]
366
+ * @returns {JobView | null}
367
+ */
222
368
  view(jobId, { detail } = {}) {
223
369
  const job = this.jobs.get(jobId);
224
370
  if (!job) {
225
371
  const rec = this.#fromDisk(jobId);
226
372
  return rec ? compactBrief(this.#diskView(rec), detail) : null;
227
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} */
228
384
  const rec = this.#record(job);
229
385
  rec.cancellable = job.status === 'running' || job.status === 'queued';
230
386
  // While it runs, say what it is doing. "running" for twenty minutes tells
@@ -259,10 +415,18 @@ export class JobManager {
259
415
  // job_id and group_id can still be followed up and recorded against. The
260
416
  // index only picks and orders; each row shown is read from its round, which
261
417
  // holds the verdicts written since. Memory wins over disk for the same job.
418
+ /** @param {{limit?: number}} [opts] */
262
419
  list({ limit = 20 } = {}) {
263
- const here = this.order.map((jid) => this.jobs.get(jid));
264
- const seen = new Set(here.map((j) => j.job_id));
265
- const elsewhere = [];
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
+ }
266
430
  // Read only: a marker whose owner is gone is shown as interrupted, and
267
431
  // left for consult_get or the startup sweep to close out.
268
432
  for (const jobId of store.listRunning()) {
@@ -272,21 +436,24 @@ export class JobManager {
272
436
  const state = ownerState(jobId, live.owner);
273
437
  // A round saved just before its owner died is the answer, whatever the marker says.
274
438
  const saved = state === 'alive' ? null : store.loadRoundAt(live);
275
- elsewhere.push({
276
- rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
277
- });
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 });
278
445
  }
279
446
  for (const row of store.indexRows())
280
447
  if (!seen.has(row.job_id)) {
281
448
  seen.add(row.job_id);
282
- elsewhere.push({ row });
449
+ candidates.push({ at: row.created_at ?? '', load: () => store.loadRoundAt(row) });
283
450
  }
284
- const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
451
+ /** @type {Round[]} */
285
452
  const picked = [];
286
- // Round files are read only for the rows shown; one that cannot be read is passed over.
287
- for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
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))) {
288
455
  if (picked.length >= limit) break;
289
- const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
456
+ const j = x.load();
290
457
  if (j) picked.push(j);
291
458
  }
292
459
  return picked.map((j) => {
@@ -321,10 +488,12 @@ export class JobManager {
321
488
  * but named under `not_reported`. Nothing is estimated.
322
489
  */
323
490
  usageTotals() {
491
+ /** @type {Record<string, Record<string, number> & {consultations: number, attempts: number}>} */
324
492
  const by = {};
493
+ /** @type {Record<string, Set<string>>} */
325
494
  const reported = {};
326
495
  for (const j of this.jobs.values()) {
327
- 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);
328
497
  if (!attempts.length) continue;
329
498
  const t = (by[j.target] ??= { consultations: 0, attempts: 0 });
330
499
  const seen = (reported[j.target] ??= new Set());
@@ -338,12 +507,15 @@ export class JobManager {
338
507
  }
339
508
  }
340
509
  }
510
+ /** @type {Record<string, Record<string, number | string[]>>} */
511
+ const out = {};
341
512
  for (const [target, t] of Object.entries(by)) {
342
- 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)) };
343
514
  }
344
- return by;
515
+ return out;
345
516
  }
346
517
 
518
+ /** @param {RawRequest} rawRequest the tool arguments, not yet validated */
347
519
  start(rawRequest) {
348
520
  if (process.env.SEVERALLY_ACTIVE === '1') {
349
521
  throw new RequestError(
@@ -359,7 +531,9 @@ export class JobManager {
359
531
  // Resolve the chain before validating, so follow-up rules can be applied.
360
532
  const followupTo =
361
533
  typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
534
+ /** @type {(Round & {nextRound: number}) | null} */
362
535
  let parent = null;
536
+ /** @type {GroupParents | null} */
363
537
  let fromGroup = null;
364
538
  if (followupTo?.startsWith('group_')) {
365
539
  // A follow-up to a fan-out goes to every member that can take one; the
@@ -375,11 +549,11 @@ export class JobManager {
375
549
  } else if (followupTo) {
376
550
  // A job from an earlier server session is read back from the history,
377
551
  // so a restart does not end a consultation the lead is still checking.
378
- parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
379
- if (!parent) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
380
- 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') {
381
555
  throw new RequestError(
382
- `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`,
383
557
  'followup_not_completed',
384
558
  );
385
559
  }
@@ -387,23 +561,24 @@ export class JobManager {
387
561
  // round goes after every round already there: numbering it parent + 1
388
562
  // would overwrite a later round's history file. A later round that
389
563
  // failed or was cancelled does not block a retry from the parent.
390
- const rounds = this.#chainRounds(parent.chain_id);
391
- 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));
392
566
  if (later) {
393
567
  throw new RequestError(
394
- `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`,
395
569
  'followup_not_latest',
396
570
  );
397
571
  }
398
- parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
572
+ parent = { ...found, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
399
573
  }
400
574
 
401
575
  if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
402
576
  rawRequest = { ...rawRequest, target: parent.target };
403
577
  }
404
578
  // A fan-out's members were all handed the same brief, so any one of them holds it.
405
- if (parent || fromGroup)
406
- rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
579
+ const first = fromGroup?.links[0];
580
+ const inheritFrom = first ? (first.groupMember ?? first.parent) : parent;
581
+ if (inheritFrom) rawRequest = inherit(rawRequest, inheritFrom);
407
582
 
408
583
  const req = parseRequest(rawRequest, {
409
584
  isFollowup: Boolean(parent || fromGroup),
@@ -439,19 +614,22 @@ export class JobManager {
439
614
  // A follow-up's seat also names the chain and round it opens, so that two
440
615
  // servers continuing one consultation at once cannot both take a round
441
616
  // number: neither sees the other's round in its history until it ends.
442
- const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
443
- const seats = req.targets.map((target, i) => ({
444
- job_id: id('job'),
445
- ...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
446
- // Twice the budget plus an hour: the deadline is wall-clock, the budget is
447
- // not, so a suspended laptop must not make a live owner look overdue.
448
- owner: {
449
- pid: process.pid,
450
- deadline_at: new Date(
451
- Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
452
- ).toISOString(),
453
- },
454
- }));
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
+ });
455
633
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
456
634
  if (taken.chainBusy) {
457
635
  const { live, mine } = taken.chainBusy;
@@ -472,6 +650,7 @@ export class JobManager {
472
650
  // A request refused from here on gives its seats back.
473
651
  try {
474
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}>} */
475
654
  const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
476
655
  return this.#admitted(req, links, seats, fromGroup);
477
656
  } catch (err) {
@@ -484,10 +663,16 @@ export class JobManager {
484
663
  // round of its own chain. A member that is still running, never answered or
485
664
  // has used its rounds is skipped and named; if that is every member, the
486
665
  // request is refused.
666
+ /**
667
+ * @param {string} groupId
668
+ * @returns {GroupParents}
669
+ */
487
670
  #groupParents(groupId) {
488
671
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
489
672
  if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
673
+ /** @type {GroupLink[]} */
490
674
  const links = [];
675
+ /** @type {GroupParents['skipped']} */
491
676
  const skipped = [];
492
677
  for (const jid of group.job_ids) {
493
678
  const member = this.jobs.get(jid) ?? store.loadRound(jid);
@@ -501,20 +686,27 @@ export class JobManager {
501
686
  }
502
687
  const rounds = this.#chainRounds(member.chain_id);
503
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
+ }
504
697
  const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
505
698
  // The name is kept, not re-resolved: a consultant renamed or turned off
506
699
  // since is skipped, never continued by whichever one answers to it now.
507
700
  const consultant = consultantByName(member.target);
508
- const reason = !latest
509
- ? `${member.status}, so there is no answer to follow up`
510
- : latest.status !== 'completed'
701
+ const reason =
702
+ latest.status !== 'completed'
511
703
  ? `round ${latest.round} is still ${latest.status}`
512
704
  : nextRound > POLICY.maxRounds
513
705
  ? `has used its ${POLICY.maxRounds} rounds`
514
706
  : !consultant || !consultantAvailable(consultant)
515
707
  ? 'this consultant is no longer configured or available here'
516
708
  : null;
517
- 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 });
518
710
  else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
519
711
  }
520
712
  if (!links.length) {
@@ -526,21 +718,30 @@ export class JobManager {
526
718
  return { group, links, skipped };
527
719
  }
528
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
+ */
529
727
  #admitted(req, links, seats, fromGroup = null) {
530
728
  // What expose_from_parent copies again: for a group, what that fan-out
531
729
  // exposed (one manifest, shared by its members); otherwise the parent's.
532
- const parent = links[0].groupMember ?? links[0].parent;
730
+ const first = links[0];
731
+ const parent = first?.groupMember ?? first?.parent;
533
732
  const groupId = id('group');
534
733
  // Copied once for the whole request, before a round is counted or a job
535
734
  // exists, into the group's own directory: every member is then handed the
536
735
  // same bytes (the manifest carries their sha256), and a path that is
537
736
  // missing or a tree that blows the cap is the lead's to fix now rather
538
737
  // than a job that fails having already spent a round of this chain.
738
+ /** @type {ExposeManifest | null} */
539
739
  let exposeManifest = null;
540
- 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 ?? []) : [];
541
742
  if (req.context.expose_from_parent && parentEntries.length === 0) {
542
743
  throw new RequestError(
543
- `context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
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`,
544
745
  'nothing_to_inherit',
545
746
  );
546
747
  }
@@ -564,7 +765,10 @@ export class JobManager {
564
765
  if (req.context.expose_diff) {
565
766
  exposeManifest.skipped.push({ source: d.source, reason: 'replaced', exposed_as: d.exposed_as });
566
767
  } else {
567
- 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
+ }
568
772
  }
569
773
  }
570
774
  if (req.context.expose_diff) {
@@ -573,7 +777,7 @@ export class JobManager {
573
777
  if (diff) {
574
778
  req.context.artifacts = [
575
779
  ...req.context.artifacts,
576
- { name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff },
780
+ { name: `git diff ${exposeManifest.entries.at(-1)?.base}`, kind: 'diff', language: 'diff', excerpt: diff },
577
781
  ];
578
782
  }
579
783
  }
@@ -587,21 +791,27 @@ export class JobManager {
587
791
  !req.context.artifacts.length
588
792
  ) {
589
793
  throw new RequestError(
590
- `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`,
591
795
  'nothing_to_inherit',
592
796
  );
593
797
  }
594
798
  } catch (err) {
595
799
  store.cleanupJobDir(groupId);
596
- if (err instanceof RequestError || !err.code) throw err;
800
+ if (err instanceof RequestError || !(err instanceof Error) || !('code' in err) || !err.code) throw err;
597
801
  throw new RequestError(err.message, err.code === 'too_large' ? 'expose_paths_too_large' : 'expose_path_invalid');
598
802
  }
599
803
  req._snapshot = path.join(snapshot, 'workspace');
600
804
  }
601
- const members = req.targets.map((target, i) => {
602
- 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;
603
811
  const chainId = from ? from.chain_id : id('chain');
604
- const jobId = seats[i].job_id;
812
+ const jobId = seat.job_id;
813
+ const consultant = consultantNamed(target);
814
+ /** @type {Job} */
605
815
  const job = {
606
816
  job_id: jobId,
607
817
  chain_id: chainId,
@@ -638,9 +848,9 @@ export class JobManager {
638
848
  status: 'queued',
639
849
  // What this consultant is: the CLI it runs on, the model and its
640
850
  // lineage, as the operator defined it.
641
- cli: consultantByName(target).cli,
642
- model: consultantByName(target).model,
643
- vendor: consultantByName(target).vendor,
851
+ cli: consultant.cli,
852
+ model: consultant.model,
853
+ vendor: consultant.vendor,
644
854
  created_at: new Date().toISOString(),
645
855
  started_at: null,
646
856
  finished_at: null,
@@ -657,18 +867,21 @@ export class JobManager {
657
867
  this.order.push(jobId);
658
868
  LIVE_HERE.add(jobId);
659
869
  this.#watchCancels();
660
- job._owner = seats[i].owner;
870
+ job._owner = seat.owner;
661
871
  this.#mark(job);
662
872
  return job;
663
873
  });
664
874
  this.#evict();
665
875
 
876
+ const [firstMember] = members;
877
+ if (!firstMember) throw new Error('a consultation needs a target');
878
+ /** @type {Group} */
666
879
  const group = {
667
880
  group_id: groupId,
668
881
  created_at: new Date().toISOString(),
669
882
  mode: req.mode,
670
- question: members[0].question, // already redacted, and byte-identical across the group
671
- 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),
672
885
  };
673
886
  this.groups.set(groupId, group);
674
887
  try {
@@ -694,13 +907,13 @@ export class JobManager {
694
907
  if (req._snapshot) store.cleanupJobDir(groupId);
695
908
 
696
909
  // The members of a follow-up to a group can be at different rounds.
697
- const { round } = members[0];
698
- const sameRound = members.every((m) => m.round === round);
910
+ const { round } = firstMember;
911
+ const sameRound = members.every((/** @type {Job} */ m) => m.round === round);
699
912
  const common = {
700
913
  group_id: groupId,
701
914
  ...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
702
915
  ...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
703
- ...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
916
+ ...(parentEntries.length && exposeManifest ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
704
917
  mode: req.mode,
705
918
  // Counted, not refused: a brief of facts alone is a legitimate
706
919
  // consultation, but one about code that hands over no file is one the
@@ -718,7 +931,7 @@ export class JobManager {
718
931
  };
719
932
 
720
933
  if (!req.fanout) {
721
- const job = members[0];
934
+ const job = firstMember;
722
935
  return {
723
936
  ...common,
724
937
  job_id: job.job_id,
@@ -732,7 +945,7 @@ export class JobManager {
732
945
  return {
733
946
  ...common,
734
947
  question: group.question, // the redacted text every member was actually sent
735
- jobs: members.map((m) => ({
948
+ jobs: members.map((/** @type {Job} */ m) => ({
736
949
  job_id: m.job_id,
737
950
  chain_id: m.chain_id,
738
951
  target: m.target,
@@ -749,20 +962,34 @@ export class JobManager {
749
962
  // (memory wins: it can hold a verdict written since the round was saved).
750
963
  // Each point goes with the lead's verdict on it, so the consultant answers
751
964
  // knowing which of its points were checked and how they came out.
965
+ /**
966
+ * @param {string} chainId
967
+ * @returns {Round[]}
968
+ */
752
969
  #chainRounds(chainId) {
970
+ /** @type {Map<number, Round>} */
753
971
  const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
754
972
  for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
755
973
  return [...byRound.values()];
756
974
  }
757
975
 
976
+ /**
977
+ * @param {string} chainId
978
+ * @returns {PriorRound[]}
979
+ */
758
980
  #chainHistory(chainId) {
759
981
  return this.#chainRounds(chainId)
760
- .filter((j) => j.status === 'completed' && j.result)
982
+ .filter(/** @returns {j is Round & {result: ConsultResult}} */ (j) => j.status === 'completed' && Boolean(j.result))
761
983
  .sort((a, b) => a.round - b.round)
762
984
  .map((j) => {
763
985
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
764
986
  // The checked points first, then the unchecked ones, each with the
765
987
  // lead's verdict and effect when there is one.
988
+ /**
989
+ * @template {ResultItem} T
990
+ * @param {T[]} items
991
+ * @param {(x: T) => {point: string, grounds?: string | null}} shape
992
+ */
766
993
  const ordered = (items, shape) =>
767
994
  [...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
768
995
  id: x.id,
@@ -770,7 +997,7 @@ export class JobManager {
770
997
  verdict: verdicts.get(x.id)?.verdict ?? null,
771
998
  effect: verdicts.get(x.id)?.effect ?? null,
772
999
  }));
773
- const checked = (x) => x.verdict !== null;
1000
+ const checked = (/** @type {{verdict: unknown}} */ x) => x.verdict !== null;
774
1001
  const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
775
1002
  return {
776
1003
  round: j.round,
@@ -788,6 +1015,7 @@ export class JobManager {
788
1015
  #evict() {
789
1016
  while (this.order.length > POLICY.maxJobsRetained) {
790
1017
  const oldest = this.order.shift();
1018
+ if (oldest === undefined) break;
791
1019
  const j = this.jobs.get(oldest);
792
1020
  if (j && (j.status === 'running' || j.status === 'queued')) {
793
1021
  this.order.push(oldest); // never evict live work
@@ -802,6 +1030,7 @@ export class JobManager {
802
1030
  // last member there is nothing left to compare, so drop the group rather than
803
1031
  // keep answering as a complete fan-out with no answers in it (and keep the
804
1032
  // Map, which holds the question text, from growing without bound).
1033
+ /** @param {string} groupId */
805
1034
  #dropGroupIfEmpty(groupId) {
806
1035
  const group = this.groups.get(groupId);
807
1036
  if (!group) return;
@@ -809,8 +1038,15 @@ export class JobManager {
809
1038
  this.groups.delete(groupId);
810
1039
  }
811
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
+ */
812
1048
  async #execute(job, req, chain, brief) {
813
- const consultant = consultantByName(job.target);
1049
+ const consultant = consultantNamed(job.target);
814
1050
  const cli = consultant.cli;
815
1051
  const adapter = ADAPTERS[cli];
816
1052
  const workdir = store.makeWorkdir(job.job_id);
@@ -822,7 +1058,7 @@ export class JobManager {
822
1058
  try {
823
1059
  fs.cpSync(req._snapshot, path.join(workdir, 'workspace'), { recursive: true });
824
1060
  } catch (err) {
825
- 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}`);
826
1062
  }
827
1063
  }
828
1064
  // start() renders the brief and hands it in: every member of a fan-out is
@@ -830,6 +1066,7 @@ export class JobManager {
830
1066
  // added on a follow-up to the group.
831
1067
  const text = brief ?? renderBrief(req, chain);
832
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.
833
1070
  const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
834
1071
 
835
1072
  try {
@@ -907,7 +1144,7 @@ export class JobManager {
907
1144
  adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
908
1145
  null;
909
1146
 
910
- const attempt = (timeout) => {
1147
+ const attempt = (/** @type {number} */ timeout) => {
911
1148
  const { handle, done } = runChild({
912
1149
  command: invocation.command,
913
1150
  args: invocation.args,
@@ -915,7 +1152,9 @@ export class JobManager {
915
1152
  env,
916
1153
  input: text,
917
1154
  timeoutMs: timeout,
918
- onCancelSignal: (fn) => job._cancelFns.push(fn),
1155
+ onCancelSignal: (fn) => {
1156
+ job._cancelFns.push(fn);
1157
+ },
919
1158
  compactLine: adapter.compactLine,
920
1159
  settleAfter: adapter.settleAfter,
921
1160
  stopSignal: adapter.stopSignal,
@@ -933,6 +1172,7 @@ export class JobManager {
933
1172
  // Same consultant, same model, same brief: a re-run never picks another
934
1173
  // mind. Every earlier failure is kept in job.retries.
935
1174
  let run = await attempt(budgetMs);
1175
+ /** @type {import('./run.mjs').Interpreted} */
936
1176
  let interpreted;
937
1177
  let authTried = false;
938
1178
  let autoRetries = 0;
@@ -976,7 +1216,7 @@ export class JobManager {
976
1216
  interpreted = adapter.interpret({ ...run, lastMessageText });
977
1217
  // An answer after an error the CLI reported is kept only if it parses;
978
1218
  // otherwise it is that error, re-run like any other.
979
- const { recoveredError } = interpreted;
1219
+ const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
980
1220
  if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
981
1221
  interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
982
1222
  }
@@ -1020,7 +1260,7 @@ export class JobManager {
1020
1260
  at: new Date().toISOString(),
1021
1261
  usage: adapter.usageRecord(interpreted.usageRaw),
1022
1262
  });
1023
- await new Promise((resolve) => {
1263
+ await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
1024
1264
  const timer = setTimeout(resolve, wait);
1025
1265
  job._cancelFns.push(() => {
1026
1266
  clearTimeout(timer);
@@ -1079,6 +1319,8 @@ export class JobManager {
1079
1319
  `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
1080
1320
  leftover &&
1081
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),
1082
1324
  ].filter(Boolean);
1083
1325
  // No `advice_usable` verdict here. It was always true, so it carried no
1084
1326
  // information -- and worse, it read as the server vouching for advice it
@@ -1101,6 +1343,13 @@ export class JobManager {
1101
1343
  }
1102
1344
  }
1103
1345
 
1346
+ /**
1347
+ * @param {Job} job
1348
+ * @param {string} kind
1349
+ * @param {unknown} message
1350
+ * @param {string | null} [detail]
1351
+ * @returns {Job}
1352
+ */
1104
1353
  #fail(job, kind, message, detail) {
1105
1354
  if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
1106
1355
  job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
@@ -1117,6 +1366,7 @@ export class JobManager {
1117
1366
  }
1118
1367
 
1119
1368
  // The in-progress marker other processes read (see store.writeRunning).
1369
+ /** @param {Job} job */
1120
1370
  #mark(job) {
1121
1371
  try {
1122
1372
  store.writeRunning({ ...this.#record(job), owner: job._owner });
@@ -1125,6 +1375,7 @@ export class JobManager {
1125
1375
  }
1126
1376
  }
1127
1377
 
1378
+ /** @param {Job} job */
1128
1379
  #finish(job) {
1129
1380
  try {
1130
1381
  store.persistRound(this.#record(job));
@@ -1145,7 +1396,7 @@ export class JobManager {
1145
1396
  // consultant did not produce and a word outside VERDICTS, then stores what
1146
1397
  // was written. It never decides that a finding was adopted, and it never
1147
1398
  // fills in a verdict the lead did not write.
1148
- /** @param {Record<string, any>} [input] */
1399
+ /** @param {RawRecord} [input] */
1149
1400
  record(input = {}) {
1150
1401
  if (input.records !== undefined) return this.#recordMany(input);
1151
1402
  const allowed = ['job_id', 'entries', 'reflection'];
@@ -1169,6 +1420,7 @@ export class JobManager {
1169
1420
  // record is checked before anything is written, so a bad id leaves the
1170
1421
  // history as it was; then each job is written on its own, and a write that
1171
1422
  // fails is named in the answer rather than reported as saved.
1423
+ /** @param {RawRecord} input */
1172
1424
  #recordMany(input) {
1173
1425
  const extra = Object.keys(input).filter((k) => k !== 'records');
1174
1426
  if (extra.length) {
@@ -1181,10 +1433,12 @@ export class JobManager {
1181
1433
  if (!Array.isArray(records) || records.length === 0) {
1182
1434
  throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
1183
1435
  }
1436
+ /** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
1184
1437
  const problems = [];
1438
+ /** @type {RecordChange[]} */
1185
1439
  const changes = [];
1186
1440
  const seen = new Set();
1187
- records.forEach((r, i) => {
1441
+ records.forEach((/** @type {RawRecord} */ r, i) => {
1188
1442
  try {
1189
1443
  if (seen.has(r?.job_id))
1190
1444
  throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
@@ -1212,7 +1466,10 @@ export class JobManager {
1212
1466
 
1213
1467
  // Everything that can be wrong with a record, found before anything is
1214
1468
  // written: what the job's record and reflection become, or a RequestError.
1215
- /** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
1469
+ /**
1470
+ * @param {RawRecord} rec
1471
+ * @returns {RecordChange}
1472
+ */
1216
1473
  #checkRecord({ job_id: jobId, entries, reflection }) {
1217
1474
  if (entries === undefined && reflection === undefined) {
1218
1475
  throw new RequestError(
@@ -1240,7 +1497,8 @@ export class JobManager {
1240
1497
 
1241
1498
  const ids = recordableIds(job.result);
1242
1499
  const now = new Date().toISOString();
1243
- const merged = new Map((job.record?.entries ?? []).map((e) => [e.id, e]));
1500
+ /** @type {Map<string, RecordEntry>} */
1501
+ const written = new Map();
1244
1502
 
1245
1503
  for (const entry of entries ?? []) {
1246
1504
  const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
@@ -1257,7 +1515,7 @@ export class JobManager {
1257
1515
  'invalid_verdict',
1258
1516
  );
1259
1517
  }
1260
- merged.set(id, {
1518
+ written.set(id, {
1261
1519
  id,
1262
1520
  verdict,
1263
1521
  effect: text(entry?.effect),
@@ -1271,6 +1529,7 @@ export class JobManager {
1271
1529
  // lead predicted can still arrive with the evidence that settles it, and a
1272
1530
  // surprise can still be wrong, so a match/miss verdict would rate the
1273
1531
  // consultation on the wrong axis.
1532
+ /** @type {Reflection | undefined} */
1274
1533
  let newReflection;
1275
1534
  if (reflection !== undefined) {
1276
1535
  const delta = text(reflection?.delta);
@@ -1289,30 +1548,40 @@ export class JobManager {
1289
1548
  newReflection = { delta, related_item_ids: related, recorded_at: now };
1290
1549
  }
1291
1550
 
1292
- const newRecord =
1293
- entries === undefined
1294
- ? undefined
1295
- : { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
1296
- return { job, live, record: newRecord, reflection: newReflection };
1551
+ return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
1297
1552
  }
1298
1553
 
1299
- #saveRecord({ job, live, record, reflection }) {
1554
+ /** @param {RecordChange} change */
1555
+ #saveRecord({ job, live, entries, reflection }) {
1300
1556
  const was = { record: job.record, reflection: job.reflection };
1301
- if (reflection !== undefined) job.reflection = reflection;
1302
- 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;
1303
1572
  // An update to a round that already happened, so the index line stays as it
1304
1573
  // was: the history has one line per consultation, not one per edit.
1305
1574
  // Unlike the round itself, a verdict exists nowhere else once this server
1306
1575
  // exits, so a failed write is the lead's to know about, not best effort.
1307
1576
  try {
1308
- store.persistRound(live ? this.#record(job) : job, { appendIndex: false });
1577
+ store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
1309
1578
  } catch (e) {
1310
1579
  // Not saved, so not shown as recorded either.
1311
1580
  Object.assign(job, was);
1312
1581
  return {
1313
1582
  job_id: job.job_id,
1314
1583
  error: 'record_not_saved',
1315
- 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`,
1316
1585
  };
1317
1586
  }
1318
1587
  // Counts only: the entries are what the lead just wrote, and
@@ -1327,6 +1596,12 @@ export class JobManager {
1327
1596
  };
1328
1597
  }
1329
1598
 
1599
+ /**
1600
+ * @param {string} jobId
1601
+ * @param {number | undefined} waitMs
1602
+ * @param {{detail?: string | undefined}} [opts]
1603
+ * @param {AbortSignal} [signal]
1604
+ */
1330
1605
  async wait(jobId, waitMs, opts, signal) {
1331
1606
  const job = this.jobs.get(jobId);
1332
1607
  if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
@@ -1340,6 +1615,13 @@ export class JobManager {
1340
1615
 
1341
1616
  // A consultation another process runs has no promise here, so its history
1342
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
+ */
1343
1625
  async #waitOnDisk(read, waitMs, signal) {
1344
1626
  const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1345
1627
  let view = read();
@@ -1364,6 +1646,7 @@ export class JobManager {
1364
1646
  }, CANCEL_POLL_MS).unref();
1365
1647
  }
1366
1648
 
1649
+ /** @param {string} jobId */
1367
1650
  cancel(jobId) {
1368
1651
  const job = this.jobs.get(jobId);
1369
1652
  if (!job) {
@@ -1379,18 +1662,22 @@ export class JobManager {
1379
1662
  };
1380
1663
  }
1381
1664
  if (job.status !== 'running' && job.status !== 'queued') {
1382
- return { ...this.view(jobId), cancel_effect: `job was already ${job.status}` };
1665
+ return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
1383
1666
  }
1384
1667
  job.cancelRequested = true;
1385
1668
  for (const fn of job._cancelFns) fn();
1386
1669
  if (job._handle) job._handle.stop();
1387
- 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' };
1388
1671
  }
1389
1672
 
1390
1673
  // Every field here is derived from the one `members` array it is handed, so a
1391
1674
  // group view can never report one status in `members` and another in
1392
1675
  // `comparison` or `status` for the same job.
1393
- /** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
1676
+ /**
1677
+ * @param {Group} group
1678
+ * @param {JobView[]} members
1679
+ * @param {{detail?: string | undefined}} [opts]
1680
+ */
1394
1681
  #shapeGroup(group, members, { detail } = {}) {
1395
1682
  const full = detail === 'full';
1396
1683
  const cancelling = members.some((m) => m.status === 'cancelling');
@@ -1470,16 +1757,29 @@ export class JobManager {
1470
1757
  // While no one has finished there is nothing to set side by side, and an
1471
1758
  // empty column per consultant is only context spent on every poll.
1472
1759
  const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
1473
- if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
1474
- return view;
1760
+ return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
1475
1761
  }
1476
1762
 
1763
+ /**
1764
+ * @param {string} groupId
1765
+ * @param {{detail?: string | undefined}} [opts]
1766
+ */
1477
1767
  groupView(groupId, opts) {
1478
1768
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
1479
1769
  if (!group) return null;
1480
- 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
+ );
1481
1775
  }
1482
1776
 
1777
+ /**
1778
+ * @param {string} groupId
1779
+ * @param {number | undefined} waitMs
1780
+ * @param {{detail?: string | undefined}} [opts]
1781
+ * @param {AbortSignal} [signal]
1782
+ */
1483
1783
  async waitGroup(groupId, waitMs, opts, signal) {
1484
1784
  const group = this.groups.get(groupId);
1485
1785
  // A fan-out from another process or an earlier session: nothing here to
@@ -1493,6 +1793,7 @@ export class JobManager {
1493
1793
  return this.groupView(groupId, opts);
1494
1794
  }
1495
1795
 
1796
+ /** @param {string} groupId */
1496
1797
  cancelGroup(groupId) {
1497
1798
  const group = this.groups.get(groupId);
1498
1799
  if (!group) {
@@ -1519,7 +1820,7 @@ export class JobManager {
1519
1820
  // Shape the view from the cancel() results, not from a fresh read: the
1520
1821
  // children exit asynchronously, so a signalled member reads as
1521
1822
  // "cancelling" until it is reaped, and the whole payload must say so.
1522
- 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);
1523
1824
  const total = group.job_ids.length;
1524
1825
  return {
1525
1826
  ...this.#shapeGroup(group, members),
@@ -1577,8 +1878,13 @@ export class JobManager {
1577
1878
  // files of that diff are the same entry for this purpose.
1578
1879
  // ponytail: a diff recorded before 1.11 hashed its files in git's order, not
1579
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
+ */
1580
1885
  function markSinceParent(manifest, parentEntries) {
1581
- 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}`;
1582
1888
  const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
1583
1889
  for (const e of manifest.entries) {
1584
1890
  const k = key(e.source, e.kind);
@@ -1589,6 +1895,7 @@ function markSinceParent(manifest, parentEntries) {
1589
1895
  // What consult_start says a follow-up copied again: the entries it shares
1590
1896
  // with the parent round, the ones left out and why, and how many files that
1591
1897
  // added to this round.
1898
+ /** @param {ExposeManifest} manifest */
1592
1899
  function inheritedSummary(manifest) {
1593
1900
  const carried = manifest.entries.filter((e) => e.inherited);
1594
1901
  return {
@@ -1604,6 +1911,10 @@ function inheritedSummary(manifest) {
1604
1911
  // is the raw output stream, unparsed: material the lead may read, never an
1605
1912
  // answer, which is why it sits beside the failure and not in `result`.
1606
1913
  const PARTIAL_TAIL_CHARS = 8_000;
1914
+ /**
1915
+ * @param {import('./run.mjs').ChildResult} run
1916
+ * @returns {import('./store.mjs').PartialOutput | null}
1917
+ */
1607
1918
  function partialOutput(run) {
1608
1919
  const out = String(run.stdout ?? '');
1609
1920
  if (!out.trim()) return null;
@@ -1621,10 +1932,16 @@ function partialOutput(run) {
1621
1932
  // Waits for `promise` (null: for nothing), at most `ms`, and no longer than
1622
1933
  // `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
1623
1934
  // cut short leaves nothing behind.
1935
+ /**
1936
+ * @param {Promise<unknown> | null | undefined} promise
1937
+ * @param {number} ms
1938
+ * @param {AbortSignal} [signal]
1939
+ */
1624
1940
  async function settled(promise, ms, signal) {
1625
1941
  let timer;
1942
+ /** @type {(() => void) | undefined} */
1626
1943
  let stop;
1627
- const until = new Promise((resolve) => {
1944
+ const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
1628
1945
  stop = resolve;
1629
1946
  timer = setTimeout(resolve, ms);
1630
1947
  if (signal?.aborted) resolve();
@@ -1634,10 +1951,11 @@ async function settled(promise, ms, signal) {
1634
1951
  await (promise ? Promise.race([promise, until]) : until);
1635
1952
  } finally {
1636
1953
  clearTimeout(timer);
1637
- signal?.removeEventListener('abort', stop);
1954
+ if (stop) signal?.removeEventListener('abort', stop);
1638
1955
  }
1639
1956
  }
1640
1957
 
1958
+ /** @param {string | Record<string, unknown>} text */
1641
1959
  function parses(text) {
1642
1960
  try {
1643
1961
  normalizeResult(text);
@@ -1648,6 +1966,10 @@ function parses(text) {
1648
1966
  }
1649
1967
  }
1650
1968
 
1969
+ /**
1970
+ * @param {ConsultResult} result
1971
+ * @returns {string[]}
1972
+ */
1651
1973
  function recordableIds(result) {
1652
1974
  return [
1653
1975
  ...result.findings.map((f) => f.id),
@@ -1658,7 +1980,12 @@ function recordableIds(result) {
1658
1980
 
1659
1981
  // How many of each word, in the order they first appear. The only arithmetic
1660
1982
  // this server does on anyone's judgement.
1983
+ /**
1984
+ * @param {Iterable<string>} values
1985
+ * @returns {Record<string, number>}
1986
+ */
1661
1987
  function tally(values) {
1988
+ /** @type {Record<string, number>} */
1662
1989
  const out = {};
1663
1990
  for (const v of values) out[v] = (out[v] ?? 0) + 1;
1664
1991
  return out;
@@ -1667,17 +1994,27 @@ function tally(values) {
1667
1994
  // Counting is mechanical and stays that way: how many points the answer has,
1668
1995
  // how many carry a verdict, which ones do not. No judgement about whether the
1669
1996
  // consultation was worth it -- that is the lead's to draw from the entries.
1997
+ /**
1998
+ * @param {Round} job
1999
+ * @returns {JobRecord}
2000
+ */
1670
2001
  function recordSummary(job) {
1671
- const ids = recordableIds(job.result);
1672
- 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));
1673
2006
  return {
1674
- ...job.record,
1675
- verdicts: tally(job.record.entries.map((e) => e.verdict)),
2007
+ ...record,
2008
+ verdicts: tally(record.entries.map((e) => e.verdict)),
1676
2009
  coverage: { recordable: ids.length, recorded: written.size },
1677
2010
  unrecorded: ids.filter((id) => !written.has(id)),
1678
2011
  };
1679
2012
  }
1680
2013
 
2014
+ /**
2015
+ * @param {unknown} value
2016
+ * @returns {string | null}
2017
+ */
1681
2018
  function text(value) {
1682
2019
  if (typeof value !== 'string') return null;
1683
2020
  const t = redact(value).trim();
@@ -1690,23 +2027,29 @@ function text(value) {
1690
2027
  // a key left out is inherited -- one passed, even as an empty list, replaces
1691
2028
  // it -- and the validation that follows sees the result. Files are not:
1692
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
+ */
1693
2035
  function inherit(raw, round) {
1694
2036
  const { brief } = round;
1695
2037
  // A round kept off the web stays off: the material it carries over is the same.
2038
+ /** @type {RawRequest} */
1696
2039
  const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1697
2040
  if (!brief) return out;
1698
- for (const k of ['objective', 'success_criteria', 'constraints']) {
1699
- if (
1700
- out[k] === undefined &&
1701
- raw.context?.[k] === undefined &&
1702
- brief[k] != null &&
1703
- !(Array.isArray(brief[k]) && !brief[k].length)
1704
- )
1705
- out[k] = brief[k];
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;
1706
2046
  }
1707
2047
  const ctx = { ...(raw.context ?? {}) };
1708
- for (const k of ['facts', 'proposal', 'counterpoints']) {
1709
- if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
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;
1710
2053
  }
1711
2054
  // The diff expose_diff added is not the lead's material: it is taken again
1712
2055
  // (or replaced) through expose_from_parent, against the files as they are now.
@@ -1723,6 +2066,7 @@ function inherit(raw, round) {
1723
2066
  return out;
1724
2067
  }
1725
2068
 
2069
+ /** @param {ReturnType<typeof normalizeResult>} normalized */
1726
2070
  function adviceCaveat(normalized) {
1727
2071
  const { result, quality } = normalized;
1728
2072
  const notes = [];
@@ -1745,6 +2089,40 @@ function adviceCaveat(normalized) {
1745
2089
  return notes.length ? notes.join('; ') : null;
1746
2090
  }
1747
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
+
1748
2126
  // A lead may legitimately name its own CLI as the consultant: a fresh child
1749
2127
  // session with no shared context is still a useful clean-context re-read.
1750
2128
  // But it is not an independent opinion -- the answer comes from the same
@@ -1752,10 +2130,17 @@ function adviceCaveat(normalized) {
1752
2130
  // lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
1753
2131
  // for a GLM lead and DeepSeek for anyone.
1754
2132
 
2133
+ /**
2134
+ * @param {string | null | undefined} caller
2135
+ * @param {string | null | undefined} callerModel
2136
+ * @param {Consultant} consultant
2137
+ * @returns {string | null}
2138
+ */
1755
2139
  function sameVendorCaveat(caller, callerModel, consultant) {
1756
2140
  // The lead's lineage: its declared model if that names one, else its CLI's
1757
2141
  // own. An unknown lineage on either side is no caveat -- not a guess.
1758
- 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);
1759
2144
  const vendor = consultant.vendor;
1760
2145
  if (!callerVendor || !vendor || callerVendor !== vendor) return null;
1761
2146
  // Say what a fresh session of the same lineage removes and what it keeps.
@@ -1785,15 +2170,20 @@ function sameVendorCaveat(caller, callerModel, consultant) {
1785
2170
  // A mechanical side-by-side. The server deliberately does not decide whether
1786
2171
  // the consultants agree: inventing agreement that is not there is exactly the
1787
2172
  // failure a second opinion is supposed to prevent.
2173
+ /**
2174
+ * @param {JobView[]} members
2175
+ * @param {number} budget
2176
+ */
1788
2177
  function comparison(members, budget) {
1789
2178
  // Each column first pays for its fixed fields; the rest is its budget.
1790
2179
  const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
1791
- 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);
1792
2182
  // What the short answers did not spend goes to the ones that were cut.
1793
2183
  const cutShort = columns.filter((c) => c.truncated).length;
1794
2184
  if (each !== Infinity && cutShort) {
1795
2185
  const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
1796
- 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));
1797
2187
  }
1798
2188
  return {
1799
2189
  by_target: columns,
@@ -1812,17 +2202,32 @@ function comparison(members, budget) {
1812
2202
  // shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
1813
2203
  // `omitted` counts the items a list could not hold, and `full_with` names the
1814
2204
  // call that has everything.
2205
+ /**
2206
+ * @param {JobView} m
2207
+ * @param {number} budget
2208
+ * @returns {ComparisonColumn}
2209
+ */
1815
2210
  function byTarget(m, budget) {
1816
2211
  const full = budget === Infinity;
1817
2212
  let left = budget;
1818
2213
  let truncated = false;
2214
+ /** @type {Record<string, number>} */
1819
2215
  const omitted = {};
1820
- const clip = (s, max) => {
2216
+ const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
1821
2217
  const out = full ? s : cut(s, max);
1822
2218
  if (out !== s) truncated = true;
1823
2219
  return out;
1824
2220
  };
1825
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
+ */
1826
2231
  const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
1827
2232
  if (full) return items.map((x) => build(x, Infinity));
1828
2233
  let share = left * part;
@@ -1851,6 +2256,7 @@ function byTarget(m, budget) {
1851
2256
  const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
1852
2257
  const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
1853
2258
  if (!full) left -= printed(summary) + printed(caveat);
2259
+ /** @type {Partial<ConsultResult>} */
1854
2260
  const r = m.result ?? {};
1855
2261
  // The findings first, with up to 60% of what is left and their points cut
1856
2262
  // as short as it takes: they are what gets recorded against, so their ids
@@ -1880,6 +2286,7 @@ function byTarget(m, budget) {
1880
2286
  (d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
1881
2287
  1,
1882
2288
  );
2289
+ /** @type {ComparisonColumn} */
1883
2290
  const entry = {
1884
2291
  job_id: m.job_id,
1885
2292
  target: m.target,
@@ -1949,6 +2356,10 @@ const MIN_TEXT_CHARS = 40;
1949
2356
  * points being checked, without the rest of the answer. A projection, not a
1950
2357
  * summary. An id the answer does not have is refused with the ones it has.
1951
2358
  */
2359
+ /**
2360
+ * @param {JobView} view
2361
+ * @param {string[]} ids
2362
+ */
1952
2363
  export function pointsOf(view, ids) {
1953
2364
  if (!view.result) {
1954
2365
  throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
@@ -1965,10 +2376,13 @@ export function pointsOf(view, ids) {
1965
2376
  const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
1966
2377
  const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
1967
2378
  const points = [...new Set(ids)].map((id) => {
1968
- const [kind, item] = Object.entries(lists)
1969
- .map(([k, l]) => [k, l.find((x) => x.id === id)])
1970
- .find(([, x]) => x);
1971
- 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 };
1972
2386
  });
1973
2387
  return {
1974
2388
  job_id: view.job_id,
@@ -1997,6 +2411,7 @@ export function pointsOf(view, ids) {
1997
2411
 
1998
2412
  // What a value takes as the server prints it, nested four levels into the
1999
2413
  // response: two-space JSON, every line indented eight more.
2414
+ /** @param {unknown} v */
2000
2415
  function printed(v) {
2001
2416
  const j = JSON.stringify(v, null, 2) ?? '';
2002
2417
  return j.length + 8 * j.split('\n').length;
@@ -2004,10 +2419,15 @@ function printed(v) {
2004
2419
 
2005
2420
  // A text cut to `max` characters as printed in JSON, so a run of characters
2006
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
+ */
2007
2427
  function cut(s, max) {
2008
2428
  if (typeof s !== 'string') return s;
2009
2429
  const room = Math.max(0, Math.floor(max));
2010
- const width = (t) => JSON.stringify(t).length - 2;
2430
+ const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
2011
2431
  if (width(s) <= room) return s;
2012
2432
  // One character of the room goes to the ellipsis, and a surrogate pair is
2013
2433
  // never split.
@@ -2021,6 +2441,11 @@ function cut(s, max) {
2021
2441
  // is named rather than repeated: its size and sha256 (of the JSON stored in
2022
2442
  // the round file, so both can be checked against it), where it is kept, and
2023
2443
  // what was copied -- skipped files counted by reason, not listed.
2444
+ /**
2445
+ * @param {FullView} view
2446
+ * @param {string} [detail]
2447
+ * @returns {JobView}
2448
+ */
2024
2449
  function compactBrief(view, detail) {
2025
2450
  if (detail === 'full' || !view.brief) return view;
2026
2451
  const json = JSON.stringify(view.brief);
@@ -2032,12 +2457,18 @@ function compactBrief(view, detail) {
2032
2457
  sha256: crypto.createHash('sha256').update(json).digest('hex'),
2033
2458
  // Under the severally home (~/.severally unless the operator moved it).
2034
2459
  history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
2035
- 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,
2036
2463
  full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
2037
2464
  },
2038
2465
  };
2039
2466
  }
2040
2467
 
2468
+ /**
2469
+ * @param {Round} job
2470
+ * @returns {string}
2471
+ */
2041
2472
  function nextStep(job) {
2042
2473
  if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
2043
2474
  if (job.status === 'completed') {
@@ -2072,9 +2503,9 @@ function nextStep(job) {
2072
2503
  // Every spelling a token could name a flag by: `--name[=v]` as written, and for
2073
2504
  // a single dash both a short flag with its value attached (`-sdanger-full-access`
2074
2505
  // in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
2075
- const flagNames = (a) => {
2506
+ const flagNames = (/** @type {string} */ a) => {
2076
2507
  if (!a.startsWith('-') || a === '-') return [];
2077
- const name = a.split('=')[0];
2508
+ const [name = ''] = a.split('=');
2078
2509
  return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
2079
2510
  };
2080
2511
 
@@ -2085,6 +2516,12 @@ const flagNames = (a) => {
2085
2516
  * adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
2086
2517
  * undo it). Values (tokens without a leading dash) are not inspected.
2087
2518
  */
2519
+ /**
2520
+ * @param {CliId} target
2521
+ * @param {readonly string[]} adapterArgs
2522
+ * @param {unknown} extra
2523
+ * @returns {string | null}
2524
+ */
2088
2525
  export function extraArgsProblem(target, adapterArgs, extra) {
2089
2526
  if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
2090
2527
  const adapter = ADAPTERS[target];
@@ -2098,11 +2535,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
2098
2535
  }
2099
2536
 
2100
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
+ */
2101
2543
  export function withExtraArgs(args, extra) {
2102
2544
  if (extra.length === 0) return args;
2103
2545
  return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
2104
2546
  }
2105
2547
 
2548
+ /**
2549
+ * @param {CliId} target
2550
+ * @param {readonly string[]} args
2551
+ */
2106
2552
  export function assertNoForbiddenFlags(target, args) {
2107
2553
  const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
2108
2554
  const hit = args.find((a) => forbidden.includes(a));