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
package/src/policy.mjs CHANGED
@@ -7,7 +7,10 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { parseJsonc } from './jsonc.mjs';
9
9
  import { findCommand } from './platform.mjs';
10
- import { buildConsultants, normalizeName } from './consultants.mjs';
10
+ import { buildConsultants, isObject, normalizeName } from './consultants.mjs';
11
+
12
+ /** @typedef {import('./consultants.mjs').Consultant} Consultant */
13
+ /** @typedef {import('./consultants.mjs').CliId} CliId */
11
14
 
12
15
  // One file, read once at start, so an operator configures a machine in a
13
16
  // single place instead of editing each client's MCP registration. Precedence
@@ -22,7 +25,9 @@ const CONFIG_PATH =
22
25
  [path.join(CONFIG_HOME, 'config.jsonc'), path.join(CONFIG_HOME, 'config.json')].find((p) => fs.existsSync(p)) ||
23
26
  path.join(CONFIG_HOME, 'config.json');
24
27
 
28
+ /** @type {string | null} */
25
29
  let configError = null;
30
+ /** @type {unknown} the parsed config file, before validation */
26
31
  const CONFIG = (() => {
27
32
  try {
28
33
  return parseJsonc(fs.readFileSync(CONFIG_PATH, 'utf8'));
@@ -30,7 +35,8 @@ const CONFIG = (() => {
30
35
  // A missing file is the normal case. A malformed one is not: silently
31
36
  // ignoring it would run the machine on defaults the operator thinks they
32
37
  // overrode, so it is surfaced on stderr when the server starts.
33
- if (err.code !== 'ENOENT') configError = `${CONFIG_PATH}: ${err.message}`;
38
+ const code = err instanceof Error && 'code' in err ? err.code : undefined;
39
+ if (code !== 'ENOENT') configError = `${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`;
34
40
  return {};
35
41
  }
36
42
  })();
@@ -50,7 +56,10 @@ if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY
50
56
  const FROM = configError ? buildConsultants({}) : BUILT;
51
57
  const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
52
58
 
53
- /** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
59
+ /**
60
+ * What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started.
61
+ * @returns {string | null}
62
+ */
54
63
  export function configProblem() {
55
64
  return configError;
56
65
  }
@@ -65,34 +74,66 @@ export function brokenConsultantNames() {
65
74
  return configError ? [] : Object.keys(BUILT.broken);
66
75
  }
67
76
 
68
- /** Why the consultant of this name is out of use because of a config mistake, or null. */
77
+ /**
78
+ * Why the consultant of this name is out of use because of a config mistake, or null.
79
+ * @param {string} name
80
+ */
69
81
  export function brokenConsultant(name) {
70
82
  const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
71
83
  return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
72
84
  }
73
85
 
74
- const usable = configError ? {} : CONFIG;
86
+ /**
87
+ * One CLI's section of the config file. `args`, `env` and `provider` are read
88
+ * only after buildConsultants has checked them (a section with a mistake is
89
+ * replaced by a disabled one in cfgCli), so they have the types given here.
90
+ * @typedef {object} CliSection
91
+ * @property {unknown} [enabled]
92
+ * @property {unknown} [note]
93
+ * @property {unknown} [bin]
94
+ * @property {string[]} [args]
95
+ * @property {Record<string, string>} [env]
96
+ * @property {Record<string, Record<string, unknown>>} [provider]
97
+ */
98
+
99
+ /** @type {Record<string, unknown>} */
100
+ const usable = configError || !isObject(CONFIG) ? {} : CONFIG;
75
101
  // A CLI whose own section has a mistake is off, whatever else the section says.
102
+ /**
103
+ * @param {string} id
104
+ * @returns {CliSection}
105
+ */
76
106
  const cfgCli = (id) => {
77
- if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
107
+ const broken = Object.hasOwn(BUILT.brokenClis, id) ? BUILT.brokenClis[id] : undefined;
108
+ if (broken && !configError) {
78
109
  return {
79
110
  enabled: false,
80
- note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}`,
111
+ note: `turned off: the config file has a mistake in clis.${id}: ${broken.join('; ')}`,
81
112
  };
82
113
  }
83
- return usable.clis && typeof usable.clis === 'object' ? (usable.clis[id] ?? {}) : {};
114
+ const def = isObject(usable.clis) ? usable.clis[id] : undefined;
115
+ // A section reaches here only when buildConsultants found no mistake in it.
116
+ return isObject(def) ? /** @type {CliSection} */ (def) : {};
84
117
  };
85
- const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
118
+ const cfgServer = isObject(usable.server) ? usable.server : {};
86
119
 
87
120
  /**
88
121
  * Is this command runnable here? Absolute/relative paths are checked as given.
89
122
  * Exported so the config generator can report what a machine has without
90
123
  * going through POLICY, which already reflects any config file it found.
124
+ * @param {string} command
91
125
  */
92
126
  export function isInstalled(command) {
93
127
  return findCommand(command) !== null;
94
128
  }
95
129
 
130
+ /**
131
+ * @param {string} name
132
+ * @param {number | null} dflt
133
+ * @param {number} min
134
+ * @param {number} max
135
+ * @returns {number | null}
136
+ */
96
137
  const num = (name, dflt, min, max) => {
97
138
  const raw = process.env[name];
98
139
  if (raw === undefined || raw === '') return dflt;
@@ -105,6 +146,14 @@ const num = (name, dflt, min, max) => {
105
146
  // the same range whichever one supplied it. Codex and OpenCode do not pass the
106
147
  // user's shell environment to an MCP server, so for them the file is the only
107
148
  // place an operator can reach without editing the client's registration.
149
+ /**
150
+ * @param {string} name
151
+ * @param {unknown} fromCfg
152
+ * @param {number} dflt
153
+ * @param {number} min
154
+ * @param {number} max
155
+ * @returns {number}
156
+ */
108
157
  const limit = (name, fromCfg, dflt, min, max) => {
109
158
  const fromEnv = num(name, null, min, max);
110
159
  if (fromEnv !== null) return fromEnv;
@@ -112,6 +161,11 @@ const limit = (name, fromCfg, dflt, min, max) => {
112
161
  return dflt;
113
162
  };
114
163
 
164
+ /**
165
+ * @param {string} name
166
+ * @param {string} dflt
167
+ * @returns {string}
168
+ */
115
169
  const str = (name, dflt) => {
116
170
  const raw = process.env[name];
117
171
  return raw === undefined || raw === '' ? dflt : raw;
@@ -120,6 +174,7 @@ const str = (name, dflt) => {
120
174
  // The CLI a lead runs in, by any everyday name. Only `caller` uses this:
121
175
  // which consultant a name reaches is consultants.mjs's resolveConsultant.
122
176
  // Object.create(null) so 'constructor' is an unknown name, not a function.
177
+ /** @type {Readonly<Record<string, CliId>>} */
123
178
  export const CALLER_ALIASES = Object.freeze(
124
179
  Object.assign(Object.create(null), {
125
180
  codex: 'codex',
@@ -140,14 +195,22 @@ export const CALLER_ALIASES = Object.freeze(
140
195
 
141
196
  export const CALLER_INPUTS = Object.freeze(Object.keys(CALLER_ALIASES));
142
197
 
143
- /** The CLI id for any accepted spelling of a caller, or null. */
198
+ /**
199
+ * The CLI id for any accepted spelling of a caller, or null.
200
+ * @param {unknown} raw
201
+ * @returns {CliId | null}
202
+ */
144
203
  export function resolveCaller(raw) {
145
204
  const key = normalizeName(raw);
146
- return typeof key === 'string' && Object.hasOwn(CALLER_ALIASES, key) ? CALLER_ALIASES[key] : null;
205
+ return typeof key === 'string' ? (CALLER_ALIASES[key] ?? null) : null;
147
206
  }
148
207
 
149
208
  // Which CLI is hosting this server. Used only to annotate a consultation that
150
209
  // goes to the caller's own vendor; it never affects permissions or limits.
210
+ /**
211
+ * @param {NodeJS.ProcessEnv} [env]
212
+ * @returns {CliId | null}
213
+ */
151
214
  export function detectCaller(env = process.env) {
152
215
  if (env.CLAUDECODE === '1' || env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
153
216
  if (env.CODEX_HOME || env.CODEX_SANDBOX || env.CODEX_SANDBOX_NETWORK_DISABLED) return 'codex';
@@ -167,12 +230,15 @@ export const VERDICTS = ['unverified', 'confirmed', 'not_applicable', 'unverifia
167
230
  // The consultant's own one-word bottom line. Also what the lead may predict
168
231
  // before asking -- the same vocabulary, so a prediction and an answer can be
169
232
  // read side by side without anyone translating between two scales.
170
- export const STANCES = ['proceed', 'do_not_proceed', 'alternative', 'undetermined'];
233
+ export const STANCES = /** @type {const} */ (['proceed', 'do_not_proceed', 'alternative', 'undetermined']);
171
234
 
172
235
  /**
173
236
  * A leading ~ is a shell courtesy, not a path: nothing expands it here, so
174
237
  * "~/.local/bin/claudex" would simply not exist and the consultant would drop
175
238
  * out of the available list without saying why. Expand it where a path is read.
239
+ * @template T
240
+ * @param {T} value
241
+ * @returns {T | string}
176
242
  */
177
243
  function expandHome(value) {
178
244
  if (typeof value !== 'string') return value;
@@ -181,6 +247,12 @@ function expandHome(value) {
181
247
  }
182
248
 
183
249
  // env > config file > built-in default, for a CLI's bin.
250
+ /**
251
+ * @param {string} id
252
+ * @param {string} envName
253
+ * @param {string} dflt
254
+ * @returns {string}
255
+ */
184
256
  const binFor = (id, envName, dflt) => {
185
257
  const fromEnv = process.env[envName];
186
258
  if (fromEnv !== undefined && fromEnv !== '') return expandHome(fromEnv);
@@ -191,11 +263,16 @@ const binFor = (id, envName, dflt) => {
191
263
  // Why a consultant is off, in the operator's words: "quota exhausted until
192
264
  // 15:00" is more use to a lead than a bare refusal, and it is the reason a CLI
193
265
  // that IS installed gets excluded.
266
+ /** @param {string} id */
194
267
  const disabledNote = (id) => {
195
268
  const n = cfgCli(id).note;
196
269
  return typeof n === 'string' && n.trim() !== '' ? n.trim() : null;
197
270
  };
198
271
 
272
+ /**
273
+ * @param {string} id
274
+ * @param {string} cli
275
+ */
199
276
  const isEnabled = (id, cli) => {
200
277
  const flag = cfgCli(id).enabled;
201
278
  if (typeof flag === 'boolean') return flag;
@@ -210,7 +287,14 @@ const OPENCODE_BIN = binFor('opencode', 'SEVERALLY_OPENCODE_BIN', 'opencode');
210
287
  // Operator-only extras for the child process (config: args, env). Config-only,
211
288
  // never from a request, and validated at launch (jobs.mjs) where the isolation
212
289
  // flags and variables they must not undo are known.
213
- const extras = (id) => ({ extraArgs: cfgCli(id).args ?? [], extraEnv: cfgCli(id).env ?? {} });
290
+ /**
291
+ * @param {string} id
292
+ * @returns {{ extraArgs: string[], extraEnv: Record<string, string> }}
293
+ */
294
+ const extras = (id) => ({
295
+ extraArgs: cfgCli(id).args ?? [],
296
+ extraEnv: cfgCli(id).env ?? {},
297
+ });
214
298
 
215
299
  export const POLICY = Object.freeze({
216
300
  home: str('SEVERALLY_HOME', path.join(os.homedir(), '.severally')),
@@ -316,6 +400,8 @@ export const POLICY = Object.freeze({
316
400
  }),
317
401
  });
318
402
 
403
+ /** @typedef {typeof POLICY} Policy */
404
+
319
405
  /**
320
406
  * Wall-clock budget for one consultation of this consultant. Read at job
321
407
  * start (not frozen into the module) so an operator restart is not needed to
@@ -323,6 +409,7 @@ export const POLICY = Object.freeze({
323
409
  * same time: a long Gemini answer should not force the same patience on every
324
410
  * other consultant. The two hour ceiling stays -- past that the answer is a
325
411
  * different request, not a slower one.
412
+ * @param {Consultant | null} [consultant]
326
413
  */
327
414
  export function timeoutMs(consultant) {
328
415
  return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
@@ -417,8 +504,12 @@ export const PROTECTED_FILE_NAMES = Object.freeze([
417
504
  'id_dsa',
418
505
  ]);
419
506
 
420
- /** Absolute protected roots, with forward slashes; `*` stands for one path segment. */
507
+ /**
508
+ * Absolute protected roots, with forward slashes; `*` stands for one path segment.
509
+ * @returns {string[]}
510
+ */
421
511
  export function protectedRoots() {
512
+ /** @param {string} p */
422
513
  const fwd = (p) => p.replaceAll('\\', '/');
423
514
  return [
424
515
  ...PROTECTED_HOME_PATHS.map((rel) => fwd(path.join(os.homedir(), rel))),
@@ -426,10 +517,14 @@ export function protectedRoots() {
426
517
  ];
427
518
  }
428
519
 
520
+ /** @param {string} s */
429
521
  const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
430
522
  // Case-insensitive where the default file system is: Windows and macOS.
431
523
  export const PATHS_CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
432
- /** Regex sources: one per protected root, matching it and anything under it. */
524
+ /**
525
+ * Regex sources: one per protected root, matching it and anything under it.
526
+ * @param {string[]} [extraRoots]
527
+ */
433
528
  export function protectedRootPatterns(extraRoots = []) {
434
529
  return [...protectedRoots(), ...extraRoots.map((r) => r.replaceAll('\\', '/'))].map(
435
530
  (r) => `^${escapeRe(r.replace(/\/+$/, '')).replaceAll('*', '[^/]+')}(/|$)`,
@@ -440,7 +535,10 @@ export function protectedNamePatterns() {
440
535
  return PROTECTED_FILE_NAMES.map((n) => `^${escapeRe(n).replaceAll('*', '.*')}$`);
441
536
  }
442
537
 
443
- /** Whether an absolute path, or the file it links to, is protected. */
538
+ /**
539
+ * Whether an absolute path, or the file it links to, is protected.
540
+ * @param {string} abs
541
+ */
444
542
  export function isProtectedPath(abs) {
445
543
  const flags = PATHS_CASE_INSENSITIVE ? 'i' : '';
446
544
  const roots = protectedRootPatterns().map((p) => new RegExp(p, flags));
@@ -459,12 +557,31 @@ export function isProtectedPath(abs) {
459
557
 
460
558
  export const artifactKinds = ['code', 'log', 'doc', 'data', 'diff', 'spec', 'test-output', 'config'];
461
559
 
462
- /** The consultant with this exact name, or undefined. */
560
+ /**
561
+ * The consultant with this exact name, or undefined.
562
+ * @param {string} name
563
+ */
463
564
  export function consultantByName(name) {
464
565
  return POLICY.consultants.find((c) => c.name === name);
465
566
  }
466
567
 
467
- /** Can this consultant run here: turned on itself, and its CLI usable. */
568
+ /**
569
+ * The consultant with this exact name, for a caller that holds a name it got
570
+ * from a consultant already (a started job's target). Throws when there is
571
+ * none; use consultantByName when the name may be unknown.
572
+ * @param {string} name
573
+ * @returns {Consultant}
574
+ */
575
+ export function consultantNamed(name) {
576
+ const c = consultantByName(name);
577
+ if (!c) throw new Error(`no consultant named "${name}"`);
578
+ return c;
579
+ }
580
+
581
+ /**
582
+ * Can this consultant run here: turned on itself, and its CLI usable.
583
+ * @param {Consultant} c
584
+ */
468
585
  export function consultantAvailable(c) {
469
586
  return c.enabled && POLICY.clis[c.cli].available;
470
587
  }
@@ -472,6 +589,8 @@ export function consultantAvailable(c) {
472
589
  /**
473
590
  * Why this consultant cannot be reached, or null when it can. The operator's
474
591
  * own note wins: "installed but rate-limited" is a case only they can state.
592
+ * @param {Consultant | null | undefined} c
593
+ * @returns {string | null}
475
594
  */
476
595
  export function unavailableReason(c) {
477
596
  if (!c || consultantAvailable(c)) return null;
@@ -489,6 +608,7 @@ export function availableConsultants() {
489
608
 
490
609
  export function limitsSummary() {
491
610
  const list = availableConsultants();
611
+ /** @param {(c: Consultant) => unknown} f */
492
612
  const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
493
613
  return {
494
614
  available_targets: list.map((c) => c.name),
@@ -504,16 +624,19 @@ export function limitsSummary() {
504
624
  max_concurrent_jobs: POLICY.maxConcurrent,
505
625
  max_wait_ms: POLICY.maxWaitMs,
506
626
  input_char_budget: POLICY.input.totalCharsMax,
507
- // Stated so it is true of all three consultants. Reading is allowed for
508
- // every one of them -- what keeps a consultation brief-only is that the
509
- // child starts in an empty working directory and is never told where the
510
- // repository is, not that it cannot read. Codex is additionally sandboxed
511
- // read-only rather than execution-free, so do not advertise "no execution".
627
+ // Stated so it is true of all four CLIs. Reading is allowed for every one
628
+ // of them -- what keeps a consultation brief-only is that the child starts
629
+ // in an empty working directory and is never told where the repository
630
+ // is, not that it cannot read. Only Claude Code drops its whole-disk read
631
+ // when files are exposed (claude-code.mjs). Codex is additionally
632
+ // sandboxed read-only rather than execution-free, so do not advertise
633
+ // "no execution".
512
634
  consultant_permissions:
513
- 'web search/browse and reading local files allowed, but the consultant starts in an empty working ' +
514
- 'directory and is not told where your repository is -- unless context.expose_paths names files or ' +
515
- 'directories, which are copied read-only into that working directory and are then the only part of ' +
516
- 'your repository it has; file edits, network access, MCP tools and further consultations denied; the ' +
517
- 'Codex consultant additionally has a read-only shell',
635
+ 'web search/browse and reading local files allowed; the consultant starts in an empty working ' +
636
+ 'directory and is not told where your repository is. context.expose_paths copies the files or ' +
637
+ 'directories it names read-only into that working directory, but that is not a read boundary: apart ' +
638
+ 'from the protected credential paths, Codex, Antigravity and OpenCode can read any file your account ' +
639
+ 'can, and so can Claude Code when nothing is exposed; file edits, network access, MCP tools and ' +
640
+ 'further consultations denied; the Codex consultant additionally has a read-only shell',
518
641
  };
519
642
  }
package/src/redact.mjs CHANGED
@@ -20,6 +20,10 @@ const CODE_WORDS = new Set([
20
20
  // variable reference (${X}, $X, process.env.X, os.environ[...]), and -- unless
21
21
  // it is a long value under a full PASSWORD/PASSWD name, which stays masked
22
22
  // whatever it looks like -- a dotted name, a call or a borrow (&str).
23
+ /**
24
+ * @param {string} name
25
+ * @param {string} value
26
+ */
23
27
  function looksLikeCode(name, value) {
24
28
  if (
25
29
  CODE_WORDS.has(value.toLowerCase()) ||
@@ -28,12 +32,12 @@ function looksLikeCode(name, value) {
28
32
  /^(?:process\.env\.|os\.environ\b)/.test(value)
29
33
  )
30
34
  return true;
31
- if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1))) return false;
35
+ if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1) ?? '')) return false;
32
36
  return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith('&');
33
37
  }
34
38
 
35
39
  // A replacement is a string or a function of the match.
36
- /** @type {Array<[RegExp, any]>} */
40
+ /** @type {Array<[RegExp, string | ((...groups: string[]) => string)]>} */
37
41
  const PATTERNS = [
38
42
  // Provider API keys
39
43
  [/\bsk-ant-[A-Za-z0-9_-]{12,}/g, '[REDACTED:anthropic-key]'],
@@ -75,15 +79,29 @@ const PATTERNS = [
75
79
  [/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
76
80
  ];
77
81
 
82
+ /**
83
+ * Any JSON-like value in, the same shape out with its strings scrubbed.
84
+ * @template T
85
+ * @overload
86
+ * @param {T} value
87
+ * @returns {T}
88
+ */
89
+ /**
90
+ * @param {unknown} value
91
+ * @returns {unknown}
92
+ */
78
93
  export function redact(value) {
79
94
  if (value === null || value === undefined) return value;
80
95
  if (typeof value === 'string') {
96
+ /** @type {string} */
81
97
  let out = value;
82
- for (const [re, rep] of PATTERNS) out = out.replace(re, rep);
98
+ // The same call either way: replace() has one overload for a string and one for a function.
99
+ for (const [re, rep] of PATTERNS) out = typeof rep === 'string' ? out.replace(re, rep) : out.replace(re, rep);
83
100
  return out;
84
101
  }
85
102
  if (Array.isArray(value)) return value.map(redact);
86
103
  if (typeof value === 'object') {
104
+ /** @type {Record<string, unknown>} */
87
105
  const out = {};
88
106
  for (const [k, v] of Object.entries(value)) out[k] = redact(v);
89
107
  return out;
@@ -91,6 +109,7 @@ export function redact(value) {
91
109
  return value;
92
110
  }
93
111
 
112
+ /** @param {unknown} text */
94
113
  export function containsSecret(text) {
95
114
  if (typeof text !== 'string') return false;
96
115
  return redact(text) !== text;
@@ -3,9 +3,18 @@
3
3
  // no validation keywords beyond type/enum) so it can be handed verbatim to
4
4
  // Codex's --output-schema and Claude Code's --json-schema.
5
5
 
6
+ /** @param {string} desc */
6
7
  const s = (desc) => ({ type: 'string', description: desc });
8
+ /**
9
+ * @param {string[]} values
10
+ * @param {string} desc
11
+ */
7
12
  const enumOf = (values, desc) => ({ type: 'string', enum: values, description: desc });
8
13
 
14
+ /**
15
+ * @param {Record<string, object>} properties
16
+ * @param {string} [description]
17
+ */
9
18
  const obj = (properties, description) => ({
10
19
  type: 'object',
11
20
  description,
@@ -14,6 +23,10 @@ const obj = (properties, description) => ({
14
23
  additionalProperties: false,
15
24
  });
16
25
 
26
+ /**
27
+ * @param {object} items
28
+ * @param {string} description
29
+ */
17
30
  const arr = (items, description) => ({ type: 'array', description, items });
18
31
 
19
32
  export const CONSULT_RESULT_SCHEMA = obj(