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/status.mjs CHANGED
@@ -37,9 +37,18 @@ const CLIENTS = [
37
37
  { label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
38
38
  ];
39
39
 
40
+ /**
41
+ * A field of whatever was thrown, or undefined when it carries none.
42
+ * @param {unknown} e
43
+ * @param {string} key
44
+ * @returns {unknown}
45
+ */
46
+ const thrown = (e, key) => (typeof e === 'object' && e !== null ? Reflect.get(e, key) : undefined);
47
+
40
48
  // "registered": the client's own listing names severally (it may still be
41
49
  // turned off there). "not registered": the client answered, without it.
42
50
  // No answer at all (a timeout, a failure to start): unknown.
51
+ /** @param {{ cmd: string, checks: string[][] }} client */
43
52
  function registration({ cmd, checks }) {
44
53
  if (!findCommand(cmd)) return 'not installed';
45
54
  let ran = false;
@@ -51,7 +60,7 @@ function registration({ cmd, checks }) {
51
60
  } catch (err) {
52
61
  // It ran and said no (`mcp get` of a missing server exits non-zero); a
53
62
  // timeout or a failure to start says nothing either way.
54
- if (typeof err.status === 'number') ran = true;
63
+ if (typeof thrown(err, 'status') === 'number') ran = true;
55
64
  }
56
65
  }
57
66
  return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
@@ -60,6 +69,7 @@ function registration({ cmd, checks }) {
60
69
  // Each CLI's own word on its login, read without sending a prompt. Only the
61
70
  // verdict is kept: `claude auth status` also prints the account's email.
62
71
  // Antigravity has no such command, so it is not checked.
72
+ /** @type {Record<string, { cmd: string, args: string[], read: (out: string, ok: boolean) => boolean | string }>} */
63
73
  const LOGIN = {
64
74
  'claude-code': { cmd: 'claude', args: ['auth', 'status', '--json'], read: (out) => JSON.parse(out).loggedIn === true },
65
75
  // Codex prints its answer on stderr; the exit status carries the verdict.
@@ -76,17 +86,19 @@ const LOGIN = {
76
86
  },
77
87
  };
78
88
 
89
+ /** @param {string} cli */
79
90
  function login(cli) {
80
91
  const check = LOGIN[cli];
81
92
  if (!check) return 'not checked (no login status command)';
82
93
  if (!findCommand(check.cmd)) return 'not installed';
94
+ /** @type {string} */
83
95
  let out;
84
96
  let ok = true;
85
97
  try {
86
98
  out = execCommandSync(check.cmd, check.args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
87
- } catch (err) {
88
- if (typeof err.status !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
89
- out = `${err.stdout ?? ''}`;
99
+ } catch (e) {
100
+ if (typeof thrown(e, 'status') !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
101
+ out = `${thrown(e, 'stdout') ?? ''}`;
90
102
  ok = false;
91
103
  }
92
104
  try {
package/src/store.mjs CHANGED
@@ -7,6 +7,204 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { POLICY } from './policy.mjs';
9
9
 
10
+ /**
11
+ * @typedef {object} JobFailure
12
+ * @property {string} kind
13
+ * @property {string} message
14
+ * @property {string | null} detail
15
+ * @property {boolean} retriable
16
+ * @property {boolean} delivered_advice
17
+ */
18
+
19
+ /**
20
+ * What the lead wrote about one point of an answer.
21
+ * @typedef {object} RecordEntry
22
+ * @property {string} id
23
+ * @property {string} verdict
24
+ * @property {string | null} effect
25
+ * @property {string | null} note
26
+ * @property {string} recorded_at
27
+ */
28
+
29
+ /**
30
+ * The lead's verdicts on a round. The counts are present in a view
31
+ * (recordSummary), not in what the history keeps.
32
+ * @typedef {object} JobRecord
33
+ * @property {string} updated_at
34
+ * @property {RecordEntry[]} entries
35
+ * @property {Record<string, number> | undefined} [verdicts]
36
+ * @property {{recordable: number, recorded: number} | undefined} [coverage]
37
+ * @property {string[] | undefined} [unrecorded]
38
+ */
39
+
40
+ /**
41
+ * What the answer added over what the lead expected, in the lead's words.
42
+ * @typedef {object} Reflection
43
+ * @property {string} delta
44
+ * @property {string[]} related_item_ids
45
+ * @property {string} recorded_at
46
+ */
47
+
48
+ /**
49
+ * Written before the consultant is launched and never sent to it.
50
+ * @typedef {object} Prediction
51
+ * @property {string} expected
52
+ * @property {string} worry
53
+ * @property {string} recorded_at
54
+ */
55
+
56
+ /** @typedef {import('./parse-result.mjs').ConsultResult} ConsultResult A consultant's normalized answer (see normalizeResult). */
57
+ /** @typedef {import('./parse-result.mjs').Finding | import('./parse-result.mjs').Unknown | import('./parse-result.mjs').NextCheck} ResultItem One finding, unknown or next check of an answer. */
58
+ /** @typedef {import('./schema.mjs').Mode} Mode */
59
+ /** @typedef {import('./run.mjs').Boundary} Boundary What the consultant could and could not do, as its launch set it. */
60
+ /** @typedef {import('./run.mjs').UsageRecord} UsageRecord */
61
+
62
+ /**
63
+ * What the server derived from a normalized answer and the checks around it.
64
+ * @typedef {object} Quality
65
+ * @property {string[]} missing_sections
66
+ * @property {number} findings_without_grounds
67
+ * @property {boolean} unsourced
68
+ * @property {Record<string, number> | undefined} [list_truncated]
69
+ * @property {import('./parse-result.mjs').EvidenceBasis | null | undefined} [evidence_basis]
70
+ * @property {string | null | undefined} [caveat]
71
+ */
72
+
73
+ /**
74
+ * The material the lead handed over, as the round keeps it (see briefRecord).
75
+ * @typedef {object} BriefRecord
76
+ * @property {string | null} objective
77
+ * @property {string[]} success_criteria
78
+ * @property {string[]} constraints
79
+ * @property {string[]} facts
80
+ * @property {string | null} proposal
81
+ * @property {string[]} counterpoints
82
+ * @property {Array<{name: string, kind: string, language: string | null, source: string | null, excerpt: string}>} artifacts
83
+ * @property {{entries: BriefExposeEntry[], totals: import('./expose-paths.mjs').ExposeTotals, skipped: import('./expose-paths.mjs').SkippedPath[]} | null} expose_paths
84
+ */
85
+
86
+ /**
87
+ * @typedef {object} BriefExposeEntry
88
+ * @property {number} index
89
+ * @property {string} source
90
+ * @property {string} exposed_as
91
+ * @property {string} kind
92
+ * @property {number} files
93
+ * @property {number} bytes
94
+ * @property {string | null} sha256
95
+ * @property {string | undefined} [base]
96
+ * @property {string[] | undefined} [changes]
97
+ * @property {boolean | undefined} [changed_since_parent]
98
+ */
99
+
100
+ /**
101
+ * The tail of what a timed-out consultant had written (raw, unparsed).
102
+ * @typedef {object} PartialOutput
103
+ * @property {true} partial
104
+ * @property {number} chars_total
105
+ * @property {boolean} capture_truncated
106
+ * @property {string} tail
107
+ * @property {string} stopped_at
108
+ * @property {string} note
109
+ */
110
+
111
+ /**
112
+ * One earlier attempt of a consultation that was run again.
113
+ * @typedef {object} RetryRecord
114
+ * @property {string} failure_kind
115
+ * @property {string} reason
116
+ * @property {string} at
117
+ * @property {UsageRecord | null} usage
118
+ */
119
+
120
+ /**
121
+ * One consultation as the history keeps it (see JobManager#record) and as the
122
+ * process running it describes it; written to history/<chain>/round-NN.json.
123
+ * @typedef {object} Round
124
+ * @property {string} job_id
125
+ * @property {string} chain_id
126
+ * @property {string | null | undefined} [group_id]
127
+ * @property {number} round
128
+ * @property {string} target
129
+ * @property {string} cli
130
+ * @property {string | null | undefined} [vendor]
131
+ * @property {string | null | undefined} [caller]
132
+ * @property {string | null | undefined} [caller_model]
133
+ * @property {string | null | undefined} [initiator]
134
+ * @property {'on' | 'off' | undefined} [network]
135
+ * @property {Boundary | null | undefined} [boundary]
136
+ * @property {RetryRecord[] | undefined} [retries]
137
+ * @property {PartialOutput | null | undefined} [partial_output]
138
+ * @property {Record<string, number> | null | undefined} [tool_calls]
139
+ * @property {string | null | undefined} [diagnostics]
140
+ * @property {Mode} mode
141
+ * @property {string} status
142
+ * @property {string} model
143
+ * @property {string} question
144
+ * @property {BriefRecord | null} brief
145
+ * @property {Prediction | null | undefined} [prediction]
146
+ * @property {Reflection | null | undefined} [reflection]
147
+ * @property {JobRecord | null | undefined} [record]
148
+ * @property {string} created_at
149
+ * @property {string | null} started_at
150
+ * @property {string | null} finished_at
151
+ * @property {number | null} duration_ms
152
+ * @property {ConsultResult | null} result
153
+ * @property {Quality | null} quality
154
+ * @property {UsageRecord | null} usage
155
+ * @property {JobFailure | null} failure
156
+ * @property {number | null | undefined} [references_count]
157
+ * @property {string | null} followup_to
158
+ * @property {number | undefined} [rounds_used]
159
+ * @property {number | undefined} [rounds_remaining]
160
+ */
161
+
162
+ /** @typedef {Round & { owner?: Seat['owner'] }} RunningRecord The in-progress marker: a round record so far, plus who runs it. */
163
+
164
+ /**
165
+ * One line of history/index.jsonl.
166
+ * @typedef {object} IndexRow
167
+ * @property {string} job_id
168
+ * @property {string} chain_id
169
+ * @property {number} round
170
+ * @property {string} target
171
+ * @property {string | null} cli
172
+ * @property {string | null} initiator
173
+ * @property {string} mode
174
+ * @property {string} status
175
+ * @property {string | null} failure_kind
176
+ * @property {string} model
177
+ * @property {string} created_at
178
+ * @property {number | null} duration_ms
179
+ */
180
+
181
+ /**
182
+ * A seat file: one running consultation, shared by every severally process on one home.
183
+ * @typedef {object} Seat
184
+ * @property {string} job_id
185
+ * @property {string | undefined} [chain_id] set on a follow-up: the chain it continues
186
+ * @property {number | undefined} [round] set on a follow-up: the round it opens
187
+ * @property {{ pid: number, deadline_at: string } | undefined} [owner]
188
+ */
189
+
190
+ /**
191
+ * What takeSeats says: `chainBusy` when the chain already has a round under way,
192
+ * otherwise `live` (the seats held) and, when the cap is hit, `elsewhere`.
193
+ * @typedef {{ ok: true, live: number, elsewhere?: undefined, chainBusy?: undefined }
194
+ * | { ok: false, live?: undefined, elsewhere?: undefined, chainBusy: { live: Seat, mine: Seat } }
195
+ * | { ok: false, live: number, elsewhere: number, chainBusy?: undefined }} SeatResult
196
+ */
197
+
198
+ /**
199
+ * A fan-out, recorded under history/groups/.
200
+ * @typedef {object} Group
201
+ * @property {string} group_id
202
+ * @property {string} created_at
203
+ * @property {Mode} mode
204
+ * @property {string} question
205
+ * @property {string[]} job_ids
206
+ */
207
+
10
208
  export function ensureDirs() {
11
209
  const home = POLICY.home;
12
210
  fs.mkdirSync(path.join(home, 'history'), { recursive: true, mode: 0o700 });
@@ -20,6 +218,7 @@ export function ensureDirs() {
20
218
  return home;
21
219
  }
22
220
 
221
+ /** @param {string} jobId */
23
222
  export function jobDir(jobId) {
24
223
  return path.join(POLICY.home, 'jobs', jobId);
25
224
  }
@@ -28,10 +227,12 @@ export function jobDir(jobId) {
28
227
  // directories, under one root: a consultant's read rules can then refuse that
29
228
  // one directory, including homes created after it started. Codex's permission
30
229
  // profile cannot express a wildcard such as jobs/*/home.
230
+ /** @param {string} jobId */
31
231
  export function sandboxHome(jobId) {
32
232
  return path.join(POLICY.home, 'homes', jobId);
33
233
  }
34
234
 
235
+ /** @param {string} jobId */
35
236
  export function makeWorkdir(jobId) {
36
237
  // Empty, dedicated, and outside any project: nothing for the consultant to
37
238
  // pick up as ambient context (no AGENTS.md / CLAUDE.md / repo files).
@@ -40,6 +241,11 @@ export function makeWorkdir(jobId) {
40
241
  return dir;
41
242
  }
42
243
 
244
+ /**
245
+ * @param {string} jobId
246
+ * @param {string} name
247
+ * @param {string | NodeJS.ArrayBufferView} content
248
+ */
43
249
  export function writeJobArtifact(jobId, name, content) {
44
250
  const dir = jobDir(jobId);
45
251
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -48,6 +254,7 @@ export function writeJobArtifact(jobId, name, content) {
48
254
  return p;
49
255
  }
50
256
 
257
+ /** @param {string} p */
51
258
  export function readIfExists(p) {
52
259
  try {
53
260
  return fs.readFileSync(p, 'utf8');
@@ -60,6 +267,10 @@ export function readIfExists(p) {
60
267
  // read back from its round file. Verdicts are written after the checking, and
61
268
  // the checking can take days, so the record must not depend on the job still
62
269
  // being in this process's memory. Returns null when the index has no such job.
270
+ /**
271
+ * @param {string} jobId
272
+ * @returns {IndexRow | null}
273
+ */
63
274
  function indexRow(jobId) {
64
275
  for (const line of readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n')) {
65
276
  if (!line.trim()) continue;
@@ -73,7 +284,10 @@ function indexRow(jobId) {
73
284
  return null;
74
285
  }
75
286
 
76
- /** Every row of the history index, in the order written; a damaged line is skipped. */
287
+ /**
288
+ * Every row of the history index, in the order written; a damaged line is skipped.
289
+ * @returns {IndexRow[]}
290
+ */
77
291
  export function indexRows() {
78
292
  return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl'))
79
293
  .split('\n')
@@ -87,7 +301,11 @@ export function indexRows() {
87
301
  });
88
302
  }
89
303
 
90
- /** The round file an index row points at, or null. */
304
+ /**
305
+ * The round file an index row points at, or null.
306
+ * @param {{ chain_id: string, round: number }} row
307
+ * @returns {Round | null}
308
+ */
91
309
  export function loadRoundAt(row) {
92
310
  try {
93
311
  return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8'));
@@ -96,10 +314,19 @@ export function loadRoundAt(row) {
96
314
  }
97
315
  }
98
316
 
317
+ /**
318
+ * @param {string} chainId
319
+ * @param {number} round
320
+ */
99
321
  export const roundPath = (chainId, round) =>
100
322
  path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
101
323
 
102
- /** Kept beside the round when a consultant did not end on its own; `text` is already redacted. */
324
+ /**
325
+ * Kept beside the round when a consultant did not end on its own; `text` is already redacted.
326
+ * @param {string} chainId
327
+ * @param {number} round
328
+ * @param {string} text
329
+ */
103
330
  export function writeDiagnostics(chainId, round, text) {
104
331
  fs.mkdirSync(path.join(POLICY.home, 'history', chainId), { recursive: true, mode: 0o700 });
105
332
  const file = roundPath(chainId, round).replace(/\.json$/, '.diagnostics.txt');
@@ -107,6 +334,10 @@ export function writeDiagnostics(chainId, round, text) {
107
334
  return file;
108
335
  }
109
336
 
337
+ /**
338
+ * @param {string} jobId
339
+ * @returns {Round | null}
340
+ */
110
341
  export function loadRound(jobId) {
111
342
  const row = indexRow(jobId);
112
343
  if (!row) return null;
@@ -119,8 +350,13 @@ export function loadRound(jobId) {
119
350
 
120
351
  // Every round file of one chain, oldest first: a follow-up to a consultation
121
352
  // from an earlier server session needs the rounds before it, not just one.
353
+ /**
354
+ * @param {string} chainId
355
+ * @returns {Round[]}
356
+ */
122
357
  export function loadChain(chainId) {
123
358
  const dir = path.join(POLICY.home, 'history', chainId);
359
+ /** @type {string[]} */
124
360
  let names = [];
125
361
  try {
126
362
  names = fs
@@ -142,6 +378,10 @@ export function loadChain(chainId) {
142
378
  // `appendIndex: false` rewrites a round that has already been recorded -- the
143
379
  // lead adding a verdict to it later. index.jsonl stays one line per
144
380
  // consultation, so counting rounds there never counts edits.
381
+ /**
382
+ * @param {Round} record
383
+ * @param {{ appendIndex?: boolean }} [opts]
384
+ */
145
385
  export function persistRound(record, { appendIndex = true } = {}) {
146
386
  fs.mkdirSync(path.join(POLICY.home, 'history', record.chain_id), { recursive: true, mode: 0o700 });
147
387
  // Written aside and renamed into place, so a write that fails part-way
@@ -189,8 +429,10 @@ export function persistRound(record, { appendIndex = true } = {}) {
189
429
  const JOB_ID = /^job_[0-9a-f]{12}$/;
190
430
  const GROUP_ID = /^group_[0-9a-f]{12}$/;
191
431
  const runningDir = () => path.join(POLICY.home, 'history', 'running');
432
+ /** @param {string} jobId */
192
433
  const runningFile = (jobId) => path.join(runningDir(), `${jobId}.json`);
193
434
 
435
+ /** @param {RunningRecord} record */
194
436
  export function writeRunning(record) {
195
437
  if (!JOB_ID.test(record.job_id)) return;
196
438
  fs.mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
@@ -200,6 +442,10 @@ export function writeRunning(record) {
200
442
  fs.renameSync(tmp, runningFile(record.job_id));
201
443
  }
202
444
 
445
+ /**
446
+ * @param {string} jobId
447
+ * @returns {RunningRecord | null}
448
+ */
203
449
  export function loadRunning(jobId) {
204
450
  if (!JOB_ID.test(jobId)) return null;
205
451
  try {
@@ -209,6 +455,7 @@ export function loadRunning(jobId) {
209
455
  }
210
456
  }
211
457
 
458
+ /** @returns {string[]} */
212
459
  export function listRunning() {
213
460
  try {
214
461
  return fs
@@ -220,6 +467,7 @@ export function listRunning() {
220
467
  }
221
468
  }
222
469
 
470
+ /** @param {string} jobId */
223
471
  export function removeRunning(jobId) {
224
472
  if (!JOB_ID.test(jobId)) return;
225
473
  try {
@@ -235,10 +483,16 @@ export function removeRunning(jobId) {
235
483
  // admitting at once cannot both see the last free seat. `held(seat)` says
236
484
  // whether a seat's owner is still running it; one that is not is removed.
237
485
  const seatsDir = () => path.join(POLICY.home, 'slots');
486
+ /** @param {string} jobId */
238
487
  const seatFile = (jobId) => path.join(seatsDir(), `${jobId}.json`);
239
488
  const LOCK_WAIT_MS = 2_000;
240
489
  const LOCK_STALE_MS = 10_000;
241
490
 
491
+ /**
492
+ * @template T
493
+ * @param {() => T} fn
494
+ * @returns {T}
495
+ */
242
496
  function withSeatLock(fn) {
243
497
  const lock = path.join(seatsDir(), '.lock');
244
498
  fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 });
@@ -248,7 +502,7 @@ function withSeatLock(fn) {
248
502
  fs.mkdirSync(lock);
249
503
  break;
250
504
  } catch (err) {
251
- if (err.code !== 'EEXIST') throw err;
505
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) throw err;
252
506
  // Held for a few file operations at most: one this old was left by a
253
507
  // process that died holding it.
254
508
  try {
@@ -271,9 +525,23 @@ function withSeatLock(fn) {
271
525
  }
272
526
  }
273
527
 
274
- /** Takes one seat per entry of `seats` ({ job_id, owner }), all or none. */
528
+ /**
529
+ * A seat that continues a chain names it and the round it opens together.
530
+ * @param {Seat} seat
531
+ * @returns {seat is Seat & { chain_id: string, round: number }}
532
+ */
533
+ const continuesChain = (seat) => Boolean(seat.chain_id);
534
+
535
+ /**
536
+ * Takes one seat per entry of `seats` ({ job_id, owner }), all or none.
537
+ * @param {Seat[]} seats
538
+ * @param {number} cap
539
+ * @param {(seat: Seat) => boolean} held
540
+ * @returns {SeatResult}
541
+ */
275
542
  export function takeSeats(seats, cap, held) {
276
543
  return withSeatLock(() => {
544
+ /** @type {Seat[]} */
277
545
  const live = [];
278
546
  const names = fs.readdirSync(seatsDir());
279
547
  // A cancel asked for after its consultation ended has no seat left to go with.
@@ -287,6 +555,7 @@ export function takeSeats(seats, cap, held) {
287
555
  }
288
556
  for (const name of names) {
289
557
  if (!name.endsWith('.json')) continue;
558
+ /** @type {Seat | null} */
290
559
  let seat = null;
291
560
  try {
292
561
  seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8'));
@@ -304,7 +573,8 @@ export function takeSeats(seats, cap, held) {
304
573
  // A chain runs one round at a time, across processes: a live seat on the
305
574
  // same chain, or a round file already at the number chosen, means another
306
575
  // server continued it first.
307
- for (const mine of seats.filter((x) => x.chain_id)) {
576
+ for (const mine of seats.filter(continuesChain)) {
577
+ /** @type {Seat | undefined} */
308
578
  let other = live.find((x) => x.chain_id === mine.chain_id);
309
579
  if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
310
580
  try {
@@ -326,6 +596,7 @@ export function takeSeats(seats, cap, held) {
326
596
  });
327
597
  }
328
598
 
599
+ /** @param {string} jobId */
329
600
  export function releaseSeat(jobId) {
330
601
  if (!JOB_ID.test(jobId)) return;
331
602
  try {
@@ -338,7 +609,9 @@ export function releaseSeat(jobId) {
338
609
 
339
610
  // A cancel asked for by a server that does not run the consultation: left
340
611
  // beside its seat, for the server that does to find and act on.
612
+ /** @param {string} jobId */
341
613
  const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
614
+ /** @param {string} jobId */
342
615
  export function requestCancel(jobId) {
343
616
  if (!JOB_ID.test(jobId)) return false;
344
617
  try {
@@ -349,7 +622,9 @@ export function requestCancel(jobId) {
349
622
  return false;
350
623
  }
351
624
  }
625
+ /** @param {string} jobId */
352
626
  export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
627
+ /** @param {string} jobId */
353
628
  export function clearCancel(jobId) {
354
629
  if (JOB_ID.test(jobId))
355
630
  try {
@@ -359,6 +634,10 @@ export function clearCancel(jobId) {
359
634
  }
360
635
  }
361
636
 
637
+ /**
638
+ * @param {string} groupId
639
+ * @returns {Group | null}
640
+ */
362
641
  export function loadGroup(groupId) {
363
642
  if (!GROUP_ID.test(groupId)) return null;
364
643
  try {
@@ -371,12 +650,14 @@ export function loadGroup(groupId) {
371
650
  // A fan-out is recorded as its own record so a later reader can tell which
372
651
  // answers were asked the same question at the same time; each member job still
373
652
  // writes its own round file under its own chain.
653
+ /** @param {Group} group */
374
654
  export function persistGroup(group) {
375
655
  const dir = path.join(POLICY.home, 'history', 'groups');
376
656
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
377
657
  fs.writeFileSync(path.join(dir, `${group.group_id}.json`), JSON.stringify(group, null, 2), { mode: 0o600 });
378
658
  }
379
659
 
660
+ /** @param {string} jobId */
380
661
  export function cleanupJobDir(jobId) {
381
662
  for (const dir of [jobDir(jobId), sandboxHome(jobId)]) {
382
663
  try {