severally-mcp 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CONFIG.md +1 -0
  2. package/README.ja.md +44 -19
  3. package/README.md +44 -18
  4. package/config.example.json +1 -1
  5. package/docs/how-it-works.ja.md +2 -0
  6. package/docs/how-it-works.md +2 -0
  7. package/package.json +3 -1
  8. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  9. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  10. package/plugins/severally/dist/severally-mcp.mjs +630 -271
  11. package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
  12. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  13. package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
  14. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  15. package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
  16. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  17. package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
  18. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  19. package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
  20. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  21. package/scripts/build.mjs +57 -54
  22. package/scripts/doctor.mjs +15 -4
  23. package/scripts/init-config.mjs +2 -1
  24. package/scripts/install.mjs +73 -9
  25. package/scripts/live-check.mjs +11 -5
  26. package/scripts/live-mcp-check.mjs +10 -4
  27. package/src/adapters/antigravity-sandbox.mjs +24 -6
  28. package/src/adapters/antigravity.mjs +80 -17
  29. package/src/adapters/claude-code.mjs +49 -8
  30. package/src/adapters/codex.mjs +56 -6
  31. package/src/adapters/opencode-sandbox.mjs +20 -2
  32. package/src/adapters/opencode.mjs +115 -19
  33. package/src/brief.mjs +84 -7
  34. package/src/consultants.mjs +193 -27
  35. package/src/cross-spawn-escape.d.ts +7 -0
  36. package/src/export.mjs +50 -6
  37. package/src/expose-paths.mjs +149 -7
  38. package/src/failures.mjs +8 -1
  39. package/src/jobs.mjs +632 -152
  40. package/src/jsonc.mjs +11 -3
  41. package/src/parse-result.mjs +91 -14
  42. package/src/platform.mjs +52 -2
  43. package/src/policy.mjs +151 -28
  44. package/src/redact.mjs +22 -3
  45. package/src/result-schema.mjs +13 -0
  46. package/src/run.mjs +158 -12
  47. package/src/schema.mjs +84 -11
  48. package/src/server.mjs +49 -14
  49. package/src/status.mjs +16 -4
  50. package/src/store.mjs +287 -6
@@ -48,6 +48,7 @@ import { sandboxHome } from '../store.mjs';
48
48
  // node:sqlite ships unflagged from Node 23 (and late 22.x); the engines field
49
49
  // allows older runtimes, where credential seeding degrades to a no-op and the
50
50
  // job fails with the CLI's own auth error instead.
51
+ /** @type {typeof import('node:sqlite').DatabaseSync | null} */
51
52
  let DatabaseSync = null;
52
53
  try {
53
54
  ({ DatabaseSync } = sqliteRequire(import.meta.url)('node:sqlite'));
@@ -103,9 +104,22 @@ function readRules() {
103
104
 
104
105
  const AUTH_REL = path.join('.local', 'share', 'opencode', 'auth.json');
105
106
 
107
+ /**
108
+ * The synthesised HOME of one consultation, as prepareSandbox returns it.
109
+ * @typedef {object} OpencodeSandbox
110
+ * @property {string} root
111
+ * @property {string} credentials 'copy' or 'missing'
112
+ * @property {string[]} tools
113
+ * @property {string} credentialsSource
114
+ * @property {Record<string, string>} env
115
+ * @property {() => void} cleanup
116
+ * @property {(provider: string) => boolean} seed insert the provider's credential into the session database
117
+ */
118
+
106
119
  /**
107
120
  * Build the synthesised HOME for one consultation.
108
- * @param {{workdir: string, network?: 'on' | 'off'}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
121
+ * @param {{workdir: string, network?: 'on' | 'off' | undefined}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
122
+ * @returns {OpencodeSandbox}
109
123
  */
110
124
  export function prepareSandbox({ workdir, network = 'on' }) {
111
125
  const root = sandboxHome(path.basename(path.dirname(workdir)));
@@ -163,6 +177,7 @@ export function prepareSandbox({ workdir, network = 'on' }) {
163
177
  credentials = 'copy';
164
178
  }
165
179
 
180
+ /** @type {OpencodeSandbox} */
166
181
  const sandbox = {
167
182
  root,
168
183
  credentials,
@@ -198,8 +213,8 @@ export function prepareSandbox({ workdir, network = 'on' }) {
198
213
  /* best effort */
199
214
  }
200
215
  },
216
+ seed: (provider) => seedCredentialFromAuth(sandbox, provider),
201
217
  };
202
- sandbox.seed = (provider) => seedCredentialFromAuth(sandbox, provider);
203
218
  return sandbox;
204
219
  }
205
220
 
@@ -214,6 +229,8 @@ export function prepareSandbox({ workdir, network = 'on' }) {
214
229
  * -- note "key" (the method), not the "api" auth.json uses. Only api-key
215
230
  * entries are seeded; OAuth tokens have a different shape and lifecycle.
216
231
  *
232
+ * @param {{ root: string }} sandbox
233
+ * @param {unknown} provider
217
234
  * @returns {boolean} true when a row was written and a retry is worth it.
218
235
  */
219
236
  export function seedCredentialFromAuth(sandbox, provider) {
@@ -225,6 +242,7 @@ export function seedCredentialFromAuth(sandbox, provider) {
225
242
  // CLI's own migrations, so an unbootstrapped sandbox is simply not seedable.
226
243
  if (!fs.existsSync(dbPath)) return false;
227
244
 
245
+ /** @type {{ label: import('node:sqlite').SQLInputValue, value: string } | null} */
228
246
  let row = null;
229
247
  // The operator's session database is what the 2.x CLI itself authenticates
230
248
  // with, so it outranks the copied auth.json: an operator who rotated their
@@ -30,6 +30,16 @@ import { POLICY } from '../policy.mjs';
30
30
  import { classifyMessage } from '../failures.mjs';
31
31
  import { spawnCommand } from '../platform.mjs';
32
32
 
33
+ /** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
34
+ /** @typedef {import('../run.mjs').ChildResult} ChildResult */
35
+ /** @typedef {import('../run.mjs').Invocation} Invocation */
36
+ /** @typedef {import('../run.mjs').Interpreted} Interpreted */
37
+ /** @typedef {import('../run.mjs').Sandbox} Sandbox */
38
+ /** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
39
+ /** @typedef {import('../run.mjs').Boundary} Boundary */
40
+ /** @typedef {import('../run.mjs').InterpretedOk} OpencodeOk */
41
+ /** @typedef {import('../run.mjs').InterpretedFail} InterpretedFail */
42
+
33
43
  // Checked against opencode 1.18.31. The list exists so a future edit that
34
44
  // reaches for one of them is refused rather than quietly widening the
35
45
  // consultant:
@@ -82,21 +92,28 @@ export { prepareSandbox } from './opencode-sandbox.mjs';
82
92
  // server lives and dies with the child's process group. 1.18.31 has no such
83
93
  // flag (unknown flags die with a usage dump), so support is probed once per
84
94
  // process through `run --help` and the invocation stays 1.x-exact without it.
95
+ /** @type {boolean | null} */
85
96
  let standaloneSupport = null;
97
+ /** @type {Promise<void> | null} */
86
98
  let probePromise = null;
87
99
 
88
100
  /** Probe the CLI's capabilities once per process; safe to call per job. */
89
101
  export async function prepare() {
90
102
  if (standaloneSupport !== null) return;
91
103
  if (!probePromise) {
92
- probePromise = new Promise((resolve) => {
104
+ probePromise = new Promise((/** @type {() => void} */ resolve) => {
93
105
  let settled = false;
106
+ /** @type {NodeJS.Timeout | null} */
94
107
  let timer = null;
95
108
  // Only a SUCCESSFUL help invocation may cache its answer: a spawn
96
109
  // error, a timeout or a nonzero exit is transient, and caching it as
97
110
  // "unsupported" would pin a 2.x install to the shared-service route
98
111
  // for the process lifetime. Transient outcomes resolve without
99
112
  // caching, so the next job probes again.
113
+ /**
114
+ * @param {boolean} supported
115
+ * @param {boolean} cache
116
+ */
100
117
  const finishProbe = (supported, cache) => {
101
118
  if (settled) return;
102
119
  settled = true;
@@ -115,10 +132,11 @@ export async function prepare() {
115
132
  );
116
133
  const child = spawnCommand(POLICY.clis.opencode.cli, ['run', '--help'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
117
134
  let out = '';
118
- child.stdout.on('data', (c) => {
135
+ // stdio above is 'pipe' for both, so the streams exist.
136
+ /** @type {NonNullable<typeof child.stdout>} */ (child.stdout).on('data', (c) => {
119
137
  out += c;
120
138
  });
121
- child.stderr.on('data', (c) => {
139
+ /** @type {NonNullable<typeof child.stderr>} */ (child.stderr).on('data', (c) => {
122
140
  out += c;
123
141
  });
124
142
  child.on('error', () => finishProbe(false, false));
@@ -162,6 +180,8 @@ export async function resetProbe() {
162
180
  * and the caller retries once. Strictly once per job; every other failure
163
181
  * kind -- an auth rejection, a usage limit, invalid output -- means a
164
182
  * respawn is wasted compute.
183
+ * @param {{ interpreted: Interpreted, sandbox: Sandbox | undefined, model: string }} args
184
+ * @returns {boolean}
165
185
  */
166
186
  export function recoverAuthFailure({ interpreted, sandbox, model }) {
167
187
  if (!interpreted || interpreted.ok !== false || interpreted.failureKind !== 'model_unavailable') return false;
@@ -176,6 +196,10 @@ export function recoverAuthFailure({ interpreted, sandbox, model }) {
176
196
  // adapter adds or leaves out by capability probe.
177
197
  export const RESERVED_FLAGS = ['--model', '--standalone', '--wizard'];
178
198
 
199
+ /**
200
+ * @param {BuildRequest} req
201
+ * @returns {Invocation}
202
+ */
179
203
  export function buildInvocation({ consultant }) {
180
204
  const t = POLICY.clis.opencode;
181
205
  const chosen = consultant.model;
@@ -204,8 +228,43 @@ export function buildInvocation({ consultant }) {
204
228
  return { command: t.cli, args, model: chosen };
205
229
  }
206
230
 
207
- /** Every complete NDJSON object opencode wrote to stdout, oldest first. */
231
+ /**
232
+ * One NDJSON event from `opencode run --format json`; the CLI controls it, so
233
+ * every field is optional. `error` is {type, message} on 2.x and
234
+ * {name, data:{message}} on 1.x.
235
+ * @typedef {object} OpencodeEvent
236
+ * @property {string} [type]
237
+ * @property {{
238
+ * type?: string,
239
+ * text?: unknown,
240
+ * time?: { end?: unknown },
241
+ * tokens?: OpencodeTokens,
242
+ * cost?: unknown,
243
+ * tool?: string,
244
+ * callID?: string,
245
+ * id?: string,
246
+ * state?: { status?: string },
247
+ * }} [part]
248
+ * @property {{ message?: unknown, name?: unknown, data?: { message?: unknown } }} [error]
249
+ */
250
+
251
+ /**
252
+ * The token figures of a step-finish part.
253
+ * @typedef {{ input?: unknown, output?: unknown, total?: unknown, reasoning?: unknown, cache_read?: unknown, cache?: { read?: unknown } }} OpencodeTokens
254
+ */
255
+
256
+ /**
257
+ * The usage figures interpret collects from the event stream.
258
+ * @typedef {{ tokens?: OpencodeTokens | null, cost?: unknown, steps?: unknown }} OpencodeUsage
259
+ */
260
+
261
+ /**
262
+ * Every complete NDJSON object opencode wrote to stdout, oldest first.
263
+ * @param {string | undefined} stdout
264
+ * @returns {OpencodeEvent[]}
265
+ */
208
266
  export function events(stdout) {
267
+ /** @type {OpencodeEvent[]} */
209
268
  const out = [];
210
269
  for (const line of (stdout || '').split('\n')) {
211
270
  const t = line.trim();
@@ -227,6 +286,8 @@ export function events(stdout) {
227
286
  * filled rawCaptureMax and cut the final answer off the stream. Nothing here
228
287
  * reads tool results: interpret wants text/step_finish/error, and progress
229
288
  * only the tool name.
289
+ * @param {string} line
290
+ * @returns {string}
230
291
  */
231
292
  export function compactLine(line) {
232
293
  if (!line.includes('"tool_use"')) return line;
@@ -242,7 +303,11 @@ export function compactLine(line) {
242
303
  }
243
304
  }
244
305
 
245
- /** One line naming the error opencode reported, in classifyMessage's terms. */
306
+ /**
307
+ * One line naming the error opencode reported, in classifyMessage's terms.
308
+ * @param {NonNullable<OpencodeEvent['error']>} error
309
+ * @returns {string}
310
+ */
246
311
  function errorMessage(error) {
247
312
  if (!error || typeof error !== 'object') return JSON.stringify(error);
248
313
  // 2.x: {type, message} -- the message is already a complete human sentence,
@@ -260,6 +325,8 @@ function errorMessage(error) {
260
325
  * log line lands). Raw stdout is never the message: it is the event stream
261
326
  * of a run that was working fine, its head says nothing about the failure, and
262
327
  * numbers inside it ("reasoning":403) would be classified as HTTP statuses.
328
+ * @param {{ errors: string[], stderr?: string | undefined, stdout?: string | undefined, code?: number | null | undefined, signal?: NodeJS.Signals | null | undefined, what: string }} run
329
+ * @returns {{ msg: string, kind: string }}
263
330
  */
264
331
  function failureMessage({ errors, stderr, stdout, code, signal, what }) {
265
332
  if (errors.length) {
@@ -276,20 +343,25 @@ function failureMessage({ errors, stderr, stdout, code, signal, what }) {
276
343
  * Parse the event stream `opencode run --format json` writes to stdout.
277
344
  * The answer is the LAST completed text part: a consultation can emit prose
278
345
  * between tool calls, and only the final part carries the reply contract.
346
+ * @param {Partial<ChildResult>} run
347
+ * @returns {OpencodeOk | InterpretedFail}
279
348
  */
280
349
  export function interpret({ stdout, stderr, code, signal = null, truncated = false }) {
281
350
  const all = events(stdout);
351
+ /** @type {string | null} */
282
352
  let text = null;
283
353
  let textIndex = -1;
284
354
  let lastErrorIndex = -1;
355
+ let lastError = '';
285
356
  let finishedAfterText = false;
286
357
  let tokens = null;
287
358
  let cost = null;
288
359
  let steps = 0;
360
+ /** @type {string[]} */
289
361
  const errors = [];
362
+ /** @type {string[]} */
290
363
  const earlierTextParts = [];
291
- for (let i = 0; i < all.length; i++) {
292
- const e = all[i];
364
+ for (const [i, e] of all.entries()) {
293
365
  const part = e.part;
294
366
  if (e.type === 'text' && part?.type === 'text' && part.time?.end && typeof part.text === 'string' && part.text.trim()) {
295
367
  // The last completed part wins as the answer, but every part that had
@@ -309,7 +381,8 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
309
381
  if (part.tokens && typeof part.tokens === 'object') tokens = part.tokens;
310
382
  if (typeof part.cost === 'number') cost = part.cost;
311
383
  } else if (e.type === 'error' && e.error) {
312
- errors.push(errorMessage(e.error));
384
+ lastError = errorMessage(e.error);
385
+ errors.push(lastError);
313
386
  lastErrorIndex = i;
314
387
  }
315
388
  }
@@ -329,7 +402,7 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
329
402
  // failure. What text exists is interim prose, and 2.x exits 0 even on
330
403
  // provider errors, so order -- not the exit code -- is what tells this
331
404
  // apart from the recovered-retry case below.
332
- const msg = errors[errors.length - 1];
405
+ const msg = lastError;
333
406
  return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw };
334
407
  }
335
408
  if (code !== 0) {
@@ -339,7 +412,7 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
339
412
  const { msg, kind } = failureMessage({ errors, stderr, stdout, code, signal, what: 'after producing an answer' });
340
413
  return { ok: false, failureKind: kind, message: msg, usageRaw };
341
414
  }
342
- if (truncated && !(finishedAfterText && all.length > 0 && all[all.length - 1].type === 'step_finish')) {
415
+ if (truncated && !(finishedAfterText && all.at(-1)?.type === 'step_finish')) {
343
416
  // stdout hit the rawCaptureMax before the consultation finished, so the
344
417
  // final answer event may never have been read. Under truncation only a
345
418
  // stream that ENDS on a step-finish -- the terminal event of a completed
@@ -362,34 +435,53 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
362
435
  return { ok: true, text, earlierTextParts, usageRaw };
363
436
  }
364
437
 
438
+ /** The tools that read a file's contents (glob only lists names); a consultation whose calls to them all failed read none of its exposed files. */
439
+ export const FILE_READ_TOOLS = ['read', 'grep'];
440
+
365
441
  /**
366
442
  * What this consultation has done so far -- read while it runs. Separates
367
443
  * "working steadily through tool calls" from "wedged on one call", which is
368
444
  * the difference between raising the budget and narrowing the brief.
369
445
  */
370
- /** Tool calls seen in the event stream, one per call id; `:error` when the call failed or was refused. */
446
+ /**
447
+ * Tool calls seen in the event stream, one per call id; `:error` when the call failed or was refused.
448
+ * @param {{ stdout?: string }} run
449
+ * @returns {Record<string, number>}
450
+ */
371
451
  export function toolCalls({ stdout }) {
452
+ /** @type {Map<unknown, string>} */
372
453
  const seen = new Map();
373
454
  for (const e of events(stdout)) {
374
455
  if (e.type === 'tool_use' && e.part?.tool) {
375
456
  seen.set(e.part.callID ?? e.part.id ?? seen.size, e.part.state?.status === 'error' ? `${e.part.tool}:error` : e.part.tool);
376
457
  }
377
458
  }
378
- return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
459
+ return [...seen.values()].reduce((/** @type {Record<string, number>} */ o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
379
460
  }
380
461
 
462
+ /**
463
+ * @param {{ stdout?: string | undefined }} run
464
+ * @returns {string | null}
465
+ */
381
466
  export function progress({ stdout }) {
382
- const seen = events(stdout).filter((e) => ['step_start', 'step_finish', 'text', 'tool_use'].includes(e.type));
383
- if (!seen.length) return null;
384
- const last = seen[seen.length - 1];
467
+ const seen = events(stdout).filter(
468
+ (e) => e.type !== undefined && ['step_start', 'step_finish', 'text', 'tool_use'].includes(e.type),
469
+ );
470
+ const last = seen.at(-1);
471
+ if (!last) return null;
385
472
  const what = last.type === 'tool_use' && last.part?.tool ? `tool ${last.part.tool}` : last.type;
386
473
  return `${seen.length} event(s), last was ${what}`;
387
474
  }
388
475
 
476
+ /**
477
+ * @param {unknown} raw the usage figures interpret collected from the event stream
478
+ * @returns {UsageRecord}
479
+ */
389
480
  export function usageRecord(raw) {
390
- const r = raw ?? {};
481
+ // interpret's usageRaw is the OpencodeUsage it built (or null).
482
+ const r = /** @type {OpencodeUsage | null} */ (raw) ?? {};
391
483
  const t = r.tokens && typeof r.tokens === 'object' ? r.tokens : {};
392
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
484
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
393
485
  const cache = t.cache && typeof t.cache === 'object' ? t.cache : {};
394
486
  return {
395
487
  input_tokens: num(t.input),
@@ -407,15 +499,19 @@ export function usageRecord(raw) {
407
499
  * What this consultant was launched with, read off the invocation: the
408
500
  * boundary record kept beside each answer. Facts about the launch, not a
409
501
  * claim about what the consultant did.
502
+ * @param {{ sandbox: Sandbox | null }} launch
503
+ * @returns {Boundary}
410
504
  */
411
505
  export function boundary({ sandbox }) {
506
+ // buildInvocation always runs with the sandbox prepareSandbox made.
507
+ const { tools } = /** @type {Sandbox} */ (sandbox);
412
508
  return {
413
509
  reads: 'whole disk',
414
510
  shell: 'none',
415
- web: sandbox.tools.includes('webfetch'),
511
+ web: tools.includes('webfetch'),
416
512
  writes: false,
417
513
  mcp: false,
418
514
  protected_paths: 'denied (permission rules)',
419
- tools: [...sandbox.tools],
515
+ tools: [...tools],
420
516
  };
421
517
  }
package/src/brief.mjs CHANGED
@@ -7,6 +7,52 @@ import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
7
7
  import { redact } from './redact.mjs';
8
8
  import { humanSize } from './expose-paths.mjs';
9
9
 
10
+ /**
11
+ * @typedef {object} BriefArtifact
12
+ * @property {string} name
13
+ * @property {string} kind
14
+ * @property {string | undefined} [language]
15
+ * @property {string | undefined} [source]
16
+ * @property {string} excerpt
17
+ */
18
+
19
+ /**
20
+ * The validated request, as far as the brief reads it.
21
+ * @typedef {object} BriefRequest
22
+ * @property {string} question
23
+ * @property {string | null | undefined} [objective]
24
+ * @property {string[]} [success_criteria]
25
+ * @property {string[]} [constraints]
26
+ * @property {'explore' | 'review' | 'debate'} mode
27
+ * @property {'on' | 'off'} [network]
28
+ * @property {{ facts?: string[], proposal?: string | null, counterpoints?: string[], artifacts: BriefArtifact[] }} context
29
+ */
30
+
31
+ /** @typedef {import('./expose-paths.mjs').ExposeManifest} ExposeManifest */
32
+
33
+ /**
34
+ * A finding, unknown or check from an earlier round, with the lead's verdict on it.
35
+ * @typedef {object} PriorPoint
36
+ * @property {string} id
37
+ * @property {string} point
38
+ * @property {string | null} [grounds]
39
+ * @property {string | null} [verdict]
40
+ * @property {string | null} [effect]
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} PriorRound
45
+ * @property {number} round
46
+ * @property {string} mode
47
+ * @property {string} question
48
+ * @property {string} summary
49
+ * @property {PriorPoint[]} [findings]
50
+ * @property {PriorPoint[]} [unknowns]
51
+ * @property {PriorPoint[]} [next_checks]
52
+ */
53
+
54
+ /** @typedef {{ round: number, priorRounds: PriorRound[] }} BriefChain */
55
+
10
56
  const MODE_BRIEFS = {
11
57
  explore: [
12
58
  'MODE: explore.',
@@ -54,7 +100,7 @@ const WEB_OFF =
54
100
  const WORKDIR_EMPTY =
55
101
  "- You are running in an empty working directory and do not have the lead's repository; everything you are meant to have is in the brief below. If something is missing, record it under `unknowns` instead of guessing or substituting an assumption.";
56
102
  const WORKDIR_EXPOSED =
57
- '- Your working directory is not empty: the lead exposed specific paths, listed under "Files available to you" below and copied read-only under ./workspace. Those are the only part of the lead\'s repository you have. If something you need is missing, record it under `unknowns` instead of guessing or substituting an assumption.';
103
+ '- Your working directory is not empty: the lead exposed specific paths, listed under "Files available to you" below and copied read-only under ./workspace. Those are the only part of the lead\'s repository the lead gave you. If something you need is missing, record it under `unknowns` instead of guessing or substituting an assumption.';
58
104
 
59
105
  const GUARDRAILS_TAIL = [
60
106
  '- Never include credentials, API keys, tokens, or environment variable values in your answer.',
@@ -70,6 +116,10 @@ const GUARDRAILS_TAIL = [
70
116
  'Output contract: reply with exactly one JSON object matching the provided schema. No prose, no markdown fences, no commentary before or after it. Empty arrays are fine; invented content is not.',
71
117
  ];
72
118
 
119
+ /**
120
+ * @param {boolean} [hasExposedPaths]
121
+ * @param {'on' | 'off'} [network]
122
+ */
73
123
  export function renderGuardrails(hasExposedPaths = false, network = 'on') {
74
124
  return [
75
125
  ...GUARDRAILS_HEAD,
@@ -85,6 +135,10 @@ export function renderGuardrails(hasExposedPaths = false, network = 'on') {
85
135
  // history holds an answer whose premises are gone -- and a finding read a month
86
136
  // later is only as good as the facts it was answering. Redacted the same way
87
137
  // renderBrief redacts the text itself, so the stored copy is what was sent.
138
+ /**
139
+ * @param {BriefRequest} req
140
+ * @param {ExposeManifest | null} [exposeManifest]
141
+ */
88
142
  export function briefRecord(req, exposeManifest = null) {
89
143
  return redact({
90
144
  objective: req.objective ?? null,
@@ -126,16 +180,22 @@ export function briefRecord(req, exposeManifest = null) {
126
180
  });
127
181
  }
128
182
 
183
+ /**
184
+ * @param {string} title
185
+ * @param {string | null | undefined} body
186
+ */
129
187
  function section(title, body) {
130
188
  if (!body) return null;
131
189
  return `## ${title}\n${body}`;
132
190
  }
133
191
 
192
+ /** @param {string[] | null | undefined} items */
134
193
  function bullets(items) {
135
194
  if (!items || items.length === 0) return null;
136
195
  return items.map((i) => `- ${i}`).join('\n');
137
196
  }
138
197
 
198
+ /** @param {BriefArtifact[]} artifacts */
139
199
  function renderArtifacts(artifacts) {
140
200
  if (!artifacts.length) return null;
141
201
  return artifacts
@@ -153,6 +213,7 @@ function renderArtifacts(artifacts) {
153
213
  const EXPOSE_LISTING_MAX = 200;
154
214
 
155
215
  // Deleted files have no copy to open; the brief says which they were.
216
+ /** @param {string[]} [changes] */
156
217
  function deletedLines(changes = []) {
157
218
  const gone = changes.filter((c) => c.endsWith(' (deleted)')).map((c) => c.slice(0, -' (deleted)'.length));
158
219
  if (!gone.length) return [];
@@ -162,6 +223,7 @@ function deletedLines(changes = []) {
162
223
  ];
163
224
  }
164
225
 
226
+ /** @param {ExposeManifest | null} manifest */
165
227
  function renderExposeManifest(manifest) {
166
228
  if (!manifest || (manifest.entries.length === 0 && manifest.skipped.length === 0)) return null;
167
229
  let room = EXPOSE_LISTING_MAX;
@@ -188,7 +250,9 @@ function renderExposeManifest(manifest) {
188
250
  ...(e.kind === 'diff' || e.kind === 'change_files' ? deletedLines(e.changes) : []),
189
251
  ];
190
252
  });
253
+ /** @param {string} reason */
191
254
  const count = (reason) => manifest.skipped.filter((x) => x.reason === reason).length;
255
+ /** @param {string} reason */
192
256
  const named = (reason) =>
193
257
  manifest.skipped
194
258
  .filter((x) => x.reason === reason)
@@ -211,7 +275,7 @@ function renderExposeManifest(manifest) {
211
275
  if (skipped.length) skipped.unshift('');
212
276
  return [
213
277
  'The lead exposed these paths; they are copied under ./workspace in your working directory and you may read',
214
- "them directly. They are the only part of the lead's repository you have -- if you need something that is",
278
+ "them directly. They are the only part of the lead's repository the lead gave you -- if you need something that is",
215
279
  'not here, record it under `unknowns` rather than guessing. Text files were passed through the same',
216
280
  'credential masking as this brief; binary files were copied unchanged.',
217
281
  '',
@@ -222,16 +286,28 @@ function renderExposeManifest(manifest) {
222
286
 
223
287
  // An earlier round's point, cut to a length that keeps a five-round chain
224
288
  // from crowding out the question it is there to inform.
289
+ /**
290
+ * @param {string} s
291
+ * @param {number} [max]
292
+ */
225
293
  const clip = (s, max = 400) => (s.length > max ? `${s.slice(0, max)}...` : s);
226
294
 
227
295
  // The earlier rounds have a budget of their own, beside the request's cap:
228
296
  // when they outgrow it, the oldest rounds are left out, and the brief says so.
229
297
  const HISTORY_CHARS = 40_000;
230
298
 
299
+ /** @param {PriorRound} p */
231
300
  function renderRound(p) {
232
301
  // Prefixed with the round, so a point from round 1 is not taken for this one's.
302
+ /** @param {string} id */
233
303
  const tag = (id) => `r${p.round}.${id}`;
304
+ /** @param {PriorPoint} x */
234
305
  const verdict = (x) => (x.verdict ? ` -- lead's verdict: ${x.verdict}${x.effect ? ` (${clip(x.effect)})` : ''}` : '');
306
+ /**
307
+ * @param {string} title
308
+ * @param {PriorPoint[] | undefined} items
309
+ * @param {(x: PriorPoint) => string} line
310
+ */
235
311
  const list = (title, items, line) => (items?.length ? [title, ...items.map(line)].join('\n') : null);
236
312
  return [
237
313
  `### Round ${p.round} (${p.mode})`,
@@ -250,9 +326,9 @@ function renderRound(p) {
250
326
  }
251
327
 
252
328
  /**
253
- * @param {object} req validated request
254
- * @param {object} chain { round, priorRounds: [{question, mode, summary, findings: [{id, point, grounds, verdict, effect}],
255
- * unknowns: [{id, point, verdict, effect}], next_checks: [{id, point, verdict, effect}]}] }
329
+ * @param {BriefRequest} req validated request
330
+ * @param {BriefChain} [chain] the round number and the earlier rounds of the chain
331
+ * @param {ExposeManifest | null} [exposeManifest]
256
332
  */
257
333
  export function renderBrief(req, chain = { round: 1, priorRounds: [] }, exposeManifest = null) {
258
334
  const parts = [];
@@ -292,8 +368,9 @@ export function renderBrief(req, chain = { round: 1, priorRounds: [] }, exposeMa
292
368
  let kept = 0;
293
369
  let used = 0;
294
370
  // Newest first: the round being followed up always goes in.
295
- for (let i = blocks.length - 1; i >= 0 && (kept === 0 || used + blocks[i].length <= HISTORY_CHARS); i--) {
296
- used += blocks[i].length;
371
+ for (const block of [...blocks].reverse()) {
372
+ if (kept > 0 && used + block.length > HISTORY_CHARS) break;
373
+ used += block.length;
297
374
  kept += 1;
298
375
  }
299
376
  const left = chain.priorRounds.slice(0, blocks.length - kept).map((p) => p.round);