severally-mcp 2.3.1 → 2.4.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.
@@ -26,7 +26,11 @@ const table = (entries) => Object.freeze(Object.assign(Object.create(null), entr
26
26
  // deliberately absent: they name model families, and a Claude model hosted on
27
27
  // OpenCode should answer to "claude" too.
28
28
  export const CLI_ALIASES = table({
29
- codex: 'codex', 'claude-code': 'claude-code', antigravity: 'antigravity', agy: 'antigravity', opencode: 'opencode',
29
+ codex: 'codex',
30
+ 'claude-code': 'claude-code',
31
+ antigravity: 'antigravity',
32
+ agy: 'antigravity',
33
+ opencode: 'opencode',
30
34
  });
31
35
 
32
36
  // Names that mean "that vendor's consultants, on any CLI".
@@ -34,7 +38,12 @@ export const VENDOR_WORDS = table({ openai: 'openai', chatgpt: 'openai', anthrop
34
38
 
35
39
  // The lineage of a model, read off the first word of its stem.
36
40
  const STEM_VENDORS = table({
37
- gpt: 'openai', claude: 'anthropic', gemini: 'google', glm: 'zai', deepseek: 'deepseek', nemotron: 'nvidia',
41
+ gpt: 'openai',
42
+ claude: 'anthropic',
43
+ gemini: 'google',
44
+ glm: 'zai',
45
+ deepseek: 'deepseek',
46
+ nemotron: 'nvidia',
38
47
  });
39
48
 
40
49
  // Built-in "higher first" orders, each used only inside one model family: an
@@ -53,13 +62,19 @@ export const RESERVED_NAMES = Object.freeze(['all']);
53
62
  /** Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code". */
54
63
  export function normalizeName(raw) {
55
64
  if (typeof raw !== 'string') return raw;
56
- return raw.trim().toLowerCase().replace(/[\s_]+/g, '-');
65
+ return raw
66
+ .trim()
67
+ .toLowerCase()
68
+ .replace(/[\s_]+/g, '-');
57
69
  }
58
70
 
59
71
  /** "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model. */
60
72
  export function modelWords(model) {
61
73
  const s = String(model ?? '').toLowerCase();
62
- return s.slice(s.lastIndexOf('/') + 1).split(/[-._#:\s]+/).filter(Boolean);
74
+ return s
75
+ .slice(s.lastIndexOf('/') + 1)
76
+ .split(/[-._#:\s]+/)
77
+ .filter(Boolean);
63
78
  }
64
79
 
65
80
  const isDigitWord = (w) => /^v?\d+$/.test(w);
@@ -89,8 +104,7 @@ export function vendorOf(model, explicit = null) {
89
104
  return STEM_VENDORS[first] ?? null;
90
105
  }
91
106
 
92
- const containsRun = (hay, needle) => needle.length > 0
93
- && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
107
+ const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
94
108
 
95
109
  /** Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no. */
96
110
  export function matchesWords(model, wanted) {
@@ -221,7 +235,10 @@ export function buildConsultants(config) {
221
235
  const global = [];
222
236
  const broken = {};
223
237
  const brokenClis = {};
224
- const fault = (msg) => { problems.push(msg); global.push(msg); };
238
+ const fault = (msg) => {
239
+ problems.push(msg);
240
+ global.push(msg);
241
+ };
225
242
  const cfg = isObject(config) ? config : {};
226
243
  if (!isObject(config)) fault('the config file must hold a JSON object');
227
244
  for (const key of Object.keys(cfg)) {
@@ -236,7 +253,8 @@ export function buildConsultants(config) {
236
253
 
237
254
  let ranking = [];
238
255
  if (cfg.ranking !== undefined) {
239
- if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== '')) ranking = cfg.ranking.map((r) => r.trim());
256
+ if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== ''))
257
+ ranking = cfg.ranking.map((r) => r.trim());
240
258
  else fault('ranking must be an array of non-empty strings');
241
259
  }
242
260
 
@@ -244,19 +262,32 @@ export function buildConsultants(config) {
244
262
  if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
245
263
  else {
246
264
  for (const [id, def] of Object.entries(cfg.clis)) {
247
- if (!CLI_IDS.includes(id)) { problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`); continue; }
265
+ if (!CLI_IDS.includes(id)) {
266
+ problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
267
+ continue;
268
+ }
248
269
  const before = problems.length;
249
- if (!isObject(def)) { problems.push(`clis.${id} must be an object`); brokenClis[id] = problems.slice(before); continue; }
250
- for (const k of Object.keys(def)) if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
270
+ if (!isObject(def)) {
271
+ problems.push(`clis.${id} must be an object`);
272
+ brokenClis[id] = problems.slice(before);
273
+ continue;
274
+ }
275
+ for (const k of Object.keys(def))
276
+ if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
251
277
  // A wrong type must not fall back to a default: enabled: "false"
252
278
  // read as "unset" would turn on the CLI the operator meant to stop.
253
- if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`clis.${id}.enabled: must be true or false`);
254
- for (const k of ['bin', 'note']) if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`clis.${id}.${k}: must be a string`);
255
- if (def.args !== undefined && !(Array.isArray(def.args) && def.args.every((a) => typeof a === 'string'))) problems.push(`clis.${id}.args: must be an array of strings`);
256
- if (def.env !== undefined && !(isObject(def.env) && Object.values(def.env).every((v) => typeof v === 'string'))) problems.push(`clis.${id}.env: must be an object of strings`);
279
+ if (def.enabled !== undefined && typeof def.enabled !== 'boolean')
280
+ problems.push(`clis.${id}.enabled: must be true or false`);
281
+ for (const k of ['bin', 'note'])
282
+ if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`clis.${id}.${k}: must be a string`);
283
+ if (def.args !== undefined && !(Array.isArray(def.args) && def.args.every((a) => typeof a === 'string')))
284
+ problems.push(`clis.${id}.args: must be an array of strings`);
285
+ if (def.env !== undefined && !(isObject(def.env) && Object.values(def.env).every((v) => typeof v === 'string')))
286
+ problems.push(`clis.${id}.env: must be an object of strings`);
257
287
  if (def.provider !== undefined) {
258
288
  if (id !== 'opencode') problems.push(`clis.${id}.provider: only OpenCode takes provider definitions`);
259
- else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject))) problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
289
+ else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject)))
290
+ problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
260
291
  }
261
292
  if (problems.length > before) brokenClis[id] = problems.slice(before);
262
293
  }
@@ -272,11 +303,22 @@ export function buildConsultants(config) {
272
303
  const seen = new Map();
273
304
  for (const [name, def] of Object.entries(cfg.consultants)) {
274
305
  const at = `consultants.${name}`;
275
- if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) { problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`); continue; }
276
- if (RESERVED_NAMES.includes(name)) { problems.push(`${at}: "${name}" is reserved`); continue; }
306
+ if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) {
307
+ problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`);
308
+ continue;
309
+ }
310
+ if (RESERVED_NAMES.includes(name)) {
311
+ problems.push(`${at}: "${name}" is reserved`);
312
+ continue;
313
+ }
277
314
  const before = problems.length;
278
- if (!isObject(def)) { problems.push(`${at} must be an object`); broken[name] = problems.slice(before); continue; }
279
- for (const k of Object.keys(def)) if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
315
+ if (!isObject(def)) {
316
+ problems.push(`${at} must be an object`);
317
+ broken[name] = problems.slice(before);
318
+ continue;
319
+ }
320
+ for (const k of Object.keys(def))
321
+ if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
280
322
  if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
281
323
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
282
324
  for (const k of ['effort', 'vendor', 'note']) {
@@ -284,12 +326,18 @@ export function buildConsultants(config) {
284
326
  }
285
327
  if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
286
328
  for (const k of ['timeout_ms', 'max_budget_usd']) {
287
- if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k]))) problems.push(`${at}.${k}: must be a number`);
329
+ if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
330
+ problems.push(`${at}.${k}: must be a number`);
288
331
  }
289
- if (def.max_budget_usd !== undefined && def.cli !== 'claude-code') problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
332
+ if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
333
+ problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
290
334
  // agy takes the effort from the model name and rejects --effort.
291
- if (def.effort !== undefined && def.cli === 'antigravity') problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
292
- if (problems.length > before) { broken[name] = problems.slice(before); continue; }
335
+ if (def.effort !== undefined && def.cli === 'antigravity')
336
+ problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
337
+ if (problems.length > before) {
338
+ broken[name] = problems.slice(before);
339
+ continue;
340
+ }
293
341
  const pair = `${def.cli}\n${def.model.trim()}`;
294
342
  if (seen.has(pair)) {
295
343
  problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
package/src/export.mjs CHANGED
@@ -24,13 +24,21 @@ export class ExportError extends Error {
24
24
  const historyDir = () => path.join(POLICY.home, 'history');
25
25
 
26
26
  function readJson(p) {
27
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
27
+ try {
28
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
28
32
  }
29
33
 
30
34
  function roundsOf(chainId) {
31
35
  const dir = path.join(historyDir(), chainId);
32
36
  let names = [];
33
- try { names = fs.readdirSync(dir); } catch { return []; }
37
+ try {
38
+ names = fs.readdirSync(dir);
39
+ } catch {
40
+ return [];
41
+ }
34
42
  return names
35
43
  .filter((n) => /^round-\d+\.json$/.test(n))
36
44
  .sort()
@@ -51,7 +59,9 @@ function chainsOfGroup(groupId) {
51
59
  const row = JSON.parse(line);
52
60
  byJob.set(row.job_id, row.chain_id);
53
61
  }
54
- } catch { /* an unreadable index leaves the group unresolvable, handled below */ }
62
+ } catch {
63
+ /* an unreadable index leaves the group unresolvable, handled below */
64
+ }
55
65
  const chains = [];
56
66
  for (const jobId of group.job_ids ?? []) {
57
67
  const chainId = byJob.get(jobId);
@@ -80,11 +90,23 @@ function briefSection(brief) {
80
90
  out.push('');
81
91
  };
82
92
  block('Objective', brief.objective);
83
- block('Success criteria', bullets(brief.success_criteria, (c) => `- ${c}`));
84
- block('Constraints', bullets(brief.constraints, (c) => `- ${c}`));
85
- block('Facts as given', bullets(brief.facts, (f) => `- ${f}`));
93
+ block(
94
+ 'Success criteria',
95
+ bullets(brief.success_criteria, (c) => `- ${c}`),
96
+ );
97
+ block(
98
+ 'Constraints',
99
+ bullets(brief.constraints, (c) => `- ${c}`),
100
+ );
101
+ block(
102
+ 'Facts as given',
103
+ bullets(brief.facts, (f) => `- ${f}`),
104
+ );
86
105
  block('Proposal', brief.proposal);
87
- block('Opposing claims', bullets(brief.counterpoints, (c) => `- ${c}`));
106
+ block(
107
+ 'Opposing claims',
108
+ bullets(brief.counterpoints, (c) => `- ${c}`),
109
+ );
88
110
  for (const a of brief.artifacts ?? []) {
89
111
  out.push(`**Material: ${a.name}** (${[a.kind, a.language, a.source].filter(Boolean).join(', ')})`, '');
90
112
  out.push(...fence(a.excerpt, a.language));
@@ -98,15 +120,21 @@ function briefSection(brief) {
98
120
  if (exposed?.entries?.length || exposed?.skipped?.length) {
99
121
  out.push('**Files exposed to the consultant**', '');
100
122
  for (const e of exposed.entries) {
101
- const since = e.changed_since_parent === undefined ? '' : e.changed_since_parent ? ', changed since the earlier round' : ', unchanged since the earlier round';
123
+ const since =
124
+ e.changed_since_parent === undefined
125
+ ? ''
126
+ : e.changed_since_parent
127
+ ? ', changed since the earlier round'
128
+ : ', unchanged since the earlier round';
102
129
  out.push(`- ${e.exposed_as} (${e.kind}, ${e.files} file(s), ${humanSize(e.bytes)}${since}) -- from ${e.source}`);
103
130
  }
104
131
  const count = (reason) => (exposed.skipped ?? []).filter((x) => x.reason === reason).length;
105
132
  if (count('symlink')) out.push(`- ${count('symlink')} symlink(s) inside those directories were skipped, not followed`);
106
133
  if (count('protected')) out.push(`- ${count('protected')} credential or history path(s) were left out on purpose`);
107
134
  if (count('gone')) out.push(`- ${count('gone')} path(s) from the earlier round no longer existed and were not copied again`);
108
- if (count('masked')) out.push(`- ${count('masked')} path(s) from the earlier round were masked in its record and were not copied again`);
109
- if (count('replaced')) out.push('- the earlier round\'s change was replaced by the diff given in this round');
135
+ if (count('masked'))
136
+ out.push(`- ${count('masked')} path(s) from the earlier round were masked in its record and were not copied again`);
137
+ if (count('replaced')) out.push("- the earlier round's change was replaced by the diff given in this round");
110
138
  out.push('');
111
139
  }
112
140
  return out;
@@ -209,7 +237,9 @@ function roundSection(round, previousBrief) {
209
237
  ];
210
238
  if (round.result) {
211
239
  head.push(`- stance as declared: ${round.result.stance ?? 'not declared'}`);
212
- head.push(`- the consultant's own confidence: ${round.result.confidence ?? 'not declared'}, evidence basis: ${round.result.evidence_basis ?? 'not declared'}`);
240
+ head.push(
241
+ `- the consultant's own confidence: ${round.result.confidence ?? 'not declared'}, evidence basis: ${round.result.evidence_basis ?? 'not declared'}`,
242
+ );
213
243
  }
214
244
  head.push('');
215
245
  head.push('### Question', '', round.question, '');
@@ -273,5 +303,12 @@ export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
273
303
  if (round.brief) previousBrief = round.brief;
274
304
  }
275
305
  }
276
- return { markdown: `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`, rounds: total, chain_ids: chainIds };
306
+ return {
307
+ markdown: `${lines
308
+ .join('\n')
309
+ .replace(/\n{3,}/g, '\n\n')
310
+ .trimEnd()}\n`,
311
+ rounds: total,
312
+ chain_ids: chainIds,
313
+ };
277
314
  }
@@ -57,15 +57,15 @@ function ensureRoom(totals, size) {
57
57
  function checkBudget(totals) {
58
58
  if (totals.files > POLICY.input.exposeFilesMax) {
59
59
  throw new ExposeError(
60
- `expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or `
61
- + 'the subdirectory that actually matters instead of the tree above them',
60
+ `expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or ` +
61
+ 'the subdirectory that actually matters instead of the tree above them',
62
62
  'too_large',
63
63
  );
64
64
  }
65
65
  if (totals.bytes > POLICY.input.exposeBytesMax) {
66
66
  throw new ExposeError(
67
- `expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files `
68
- + 'or the subdirectory that actually matters instead of the tree above them',
67
+ `expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files ` +
68
+ 'or the subdirectory that actually matters instead of the tree above them',
69
69
  'too_large',
70
70
  );
71
71
  }
@@ -158,7 +158,9 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
158
158
  try {
159
159
  listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
160
160
  ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
161
- } catch { return false; }
161
+ } catch {
162
+ return false;
163
+ }
162
164
  if (listed.length === 0) return false;
163
165
  for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
164
166
  if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
@@ -169,21 +171,43 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
169
171
  // walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
170
172
  // listed file is checked with each directory above it for the same reason.
171
173
  const parts = rel.split('/');
172
- const guarded = parts.slice(0, -1).map((_, i) => path.join(dir, ...parts.slice(0, i + 1))).find(isProtectedPath);
174
+ const guarded = parts
175
+ .slice(0, -1)
176
+ .map((_, i) => path.join(dir, ...parts.slice(0, i + 1)))
177
+ .find(isProtectedPath);
173
178
  if (guarded) {
174
179
  if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
175
180
  protectedDirs.add(guarded);
176
181
  continue;
177
182
  }
178
- if (isProtectedPath(abs)) { skipped.push({ source: abs, reason: 'protected' }); continue; }
183
+ if (isProtectedPath(abs)) {
184
+ skipped.push({ source: abs, reason: 'protected' });
185
+ continue;
186
+ }
179
187
  let stat;
180
188
  // Deleted in the work tree but still in the index: not part of the tree
181
189
  // as it is now, so not part of the copy either.
182
- try { stat = fs.lstatSync(abs); } catch { continue; }
190
+ try {
191
+ stat = fs.lstatSync(abs);
192
+ } catch {
193
+ continue;
194
+ }
183
195
  // A directory here is a submodule (or a nested repository): its own history, not this one's files.
184
- if (stat.isDirectory()) { skipped.push({ source: abs, reason: 'submodule' }); continue; }
185
- const inside = (() => { try { return fs.realpathSync(abs).startsWith(realDir + path.sep); } catch { return false; } })();
186
- if (stat.isSymbolicLink() || !inside) { skipped.push({ source: abs, reason: 'symlink' }); continue; }
196
+ if (stat.isDirectory()) {
197
+ skipped.push({ source: abs, reason: 'submodule' });
198
+ continue;
199
+ }
200
+ const inside = (() => {
201
+ try {
202
+ return fs.realpathSync(abs).startsWith(realDir + path.sep);
203
+ } catch {
204
+ return false;
205
+ }
206
+ })();
207
+ if (stat.isSymbolicLink() || !inside) {
208
+ skipped.push({ source: abs, reason: 'symlink' });
209
+ continue;
210
+ }
187
211
  if (!stat.isFile()) continue;
188
212
  onFile(abs, path.normalize(rel));
189
213
  checkBudget(totals);
@@ -304,18 +328,41 @@ function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
304
328
  const copied = [];
305
329
  for (const c of changes) {
306
330
  const abs = path.join(top, c.path);
307
- if (isProtectedPath(abs)) { manifest.skipped.push({ source: abs, reason: 'protected' }); continue; }
308
- if (c.status === 'D') { shown(c); continue; }
331
+ if (isProtectedPath(abs)) {
332
+ manifest.skipped.push({ source: abs, reason: 'protected' });
333
+ continue;
334
+ }
335
+ if (c.status === 'D') {
336
+ shown(c);
337
+ continue;
338
+ }
309
339
  let stat;
310
340
  // When `gone` is given, the paths are an earlier round's, and a skipped
311
341
  // one carries the name it had there.
312
342
  const named = gone ? { exposed_as: `workspace/repo/${c.path}` } : {};
313
- try { stat = fs.lstatSync(abs); } catch { gone?.(c); continue; }
343
+ try {
344
+ stat = fs.lstatSync(abs);
345
+ } catch {
346
+ gone?.(c);
347
+ continue;
348
+ }
314
349
  // A directory on the way that is now a link out of the repository.
315
- const inside = (() => { try { return fs.realpathSync(abs).startsWith(realTop + path.sep); } catch { return false; } })();
316
- if (stat.isSymbolicLink() || !inside) { manifest.skipped.push({ source: abs, reason: 'symlink', ...named }); continue; }
350
+ const inside = (() => {
351
+ try {
352
+ return fs.realpathSync(abs).startsWith(realTop + path.sep);
353
+ } catch {
354
+ return false;
355
+ }
356
+ })();
357
+ if (stat.isSymbolicLink() || !inside) {
358
+ manifest.skipped.push({ source: abs, reason: 'symlink', ...named });
359
+ continue;
360
+ }
317
361
  // A submodule: its commit line stays in the diff, nothing to copy.
318
- if (!stat.isFile()) { shown(c); continue; }
362
+ if (!stat.isFile()) {
363
+ shown(c);
364
+ continue;
365
+ }
319
366
  shown(c);
320
367
  copied.push({ path: c.path, size: stat.size });
321
368
  }
@@ -346,19 +393,38 @@ export function planInheritance(parentEntries, exposePaths) {
346
393
  const paths = [];
347
394
  const skipped = [];
348
395
  const diffs = [];
349
- const exists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
396
+ const exists = (p) => {
397
+ try {
398
+ fs.lstatSync(p);
399
+ return true;
400
+ } catch {
401
+ return false;
402
+ }
403
+ };
350
404
  for (const e of parentEntries) {
351
- if (String(e.source).includes('[REDACTED')) { skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as }); continue; }
352
- if (!exists(e.source)) { skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as }); continue; }
353
- if (e.kind === 'diff' || e.kind === 'change_files') { diffs.push(e); continue; }
405
+ if (String(e.source).includes('[REDACTED')) {
406
+ skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as });
407
+ continue;
408
+ }
409
+ if (!exists(e.source)) {
410
+ skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as });
411
+ continue;
412
+ }
413
+ if (e.kind === 'diff' || e.kind === 'change_files') {
414
+ diffs.push(e);
415
+ continue;
416
+ }
354
417
  paths.push({ source: e.source, index: e.index });
355
418
  }
356
419
  // New paths are numbered after every name the earlier round used.
357
420
  let next = parentEntries.reduce((n, e) => Math.max(n, e.index + 1), 0);
358
421
  for (const p of exposePaths) if (!paths.some((x) => x.source === p)) paths.push({ source: p, index: next++ });
359
422
  if (paths.length > POLICY.input.exposePathsMax) {
360
- throw new ExposeError(`expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of `
361
- + `${POLICY.input.exposePathsMax}; name fewer new paths`, 'too_large');
423
+ throw new ExposeError(
424
+ `expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of ` +
425
+ `${POLICY.input.exposePathsMax}; name fewer new paths`,
426
+ 'too_large',
427
+ );
362
428
  }
363
429
  return { paths, skipped, diffs };
364
430
  }
@@ -373,21 +439,41 @@ export function reexposeChange(workdir, entry, manifest) {
373
439
  for (const c of entry.changes ?? []) {
374
440
  const m = /^(.*) \(([^()]+)\)$/.exec(c);
375
441
  const p = m ? m[1] : c;
376
- if (p.includes('[REDACTED')) { manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` }); continue; }
442
+ if (p.includes('[REDACTED')) {
443
+ manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` });
444
+ continue;
445
+ }
377
446
  // A file the change deleted that is back now is copied as it is.
378
447
  const deleted = m?.[2] === 'deleted';
379
448
  let back = false;
380
- if (deleted) try { fs.lstatSync(path.join(entry.source, p)); back = true; } catch { /* still deleted */ }
449
+ if (deleted)
450
+ try {
451
+ fs.lstatSync(path.join(entry.source, p));
452
+ back = true;
453
+ } catch {
454
+ /* still deleted */
455
+ }
381
456
  changes.push({ path: p, status: deleted ? (back ? 'restored' : 'D') : (m?.[2] ?? 'M') });
382
457
  }
383
458
  const before = { ...manifest.totals };
384
459
  const shown = [];
385
- const { copied, sha256 } = copyRepoFiles(workdir, entry.source, changes, manifest, (c) => shown.push(c),
386
- (c) => manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }));
460
+ const { copied, sha256 } = copyRepoFiles(
461
+ workdir,
462
+ entry.source,
463
+ changes,
464
+ manifest,
465
+ (c) => shown.push(c),
466
+ (c) =>
467
+ manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }),
468
+ );
387
469
  // Nothing of the change is left to show: no entry, so a follow-up with
388
470
  // nothing else to go on is refused rather than sent with an empty copy.
389
471
  if (shown.length === 0) {
390
- try { fs.rmdirSync(path.join(workdir, 'workspace', 'repo')); } catch { /* holds another entry's files */ }
472
+ try {
473
+ fs.rmdirSync(path.join(workdir, 'workspace', 'repo'));
474
+ } catch {
475
+ /* holds another entry's files */
476
+ }
391
477
  return false;
392
478
  }
393
479
  manifest.entries.push({
@@ -415,10 +501,16 @@ function git(cwd, args) {
415
501
  // at another repository), and without taking the lead's index lock.
416
502
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('GIT_')));
417
503
  const r = spawnSync('git', ['--literal-pathspecs', '-c', 'core.fsmonitor=', '-C', cwd, ...args], {
418
- timeout: 20_000, maxBuffer: 64 * 1024 * 1024, windowsHide: true, env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
504
+ timeout: 20_000,
505
+ maxBuffer: 64 * 1024 * 1024,
506
+ windowsHide: true,
507
+ env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
419
508
  });
420
509
  if (r.error || r.status !== 0) {
421
- const why = r.error?.code === 'ENOENT' ? 'git is not on PATH' : String(r.stderr ?? r.error?.message ?? '').trim();
510
+ const why =
511
+ /** @type {NodeJS.ErrnoException} */ (r.error)?.code === 'ENOENT'
512
+ ? 'git is not on PATH'
513
+ : String(r.stderr ?? r.error?.message ?? '').trim();
422
514
  throw new ExposeError(`context.expose_diff: git ${args[0]} failed in "${cwd}": ${why}`, 'not_found');
423
515
  }
424
516
  return r.stdout;
@@ -437,18 +529,23 @@ const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
437
529
  export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars) {
438
530
  const top = git(repo, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
439
531
  git(top, ['rev-parse', '--verify', `${base}^{commit}`]);
440
- const tokens = nulSplit(git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff',
441
- '--no-textconv', base, '--']));
532
+ const tokens = nulSplit(
533
+ git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff', '--no-textconv', base, '--']),
534
+ );
442
535
  const changes = [];
443
536
  for (let i = 0; i + 1 < tokens.length; i += 2) changes.push({ status: tokens[i], path: tokens[i + 1] });
444
- for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z']))) changes.push({ status: 'untracked', path: p });
537
+ for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z'])))
538
+ changes.push({ status: 'untracked', path: p });
445
539
 
446
540
  const before = { ...manifest.totals };
447
541
  const shown = [];
448
542
  const { copied, sha256 } = copyRepoFiles(workdir, top, changes, manifest, (c) => shown.push(c));
449
543
  if (shown.length === 0) {
450
- throw new ExposeError(`context.expose_diff: no change against ${base} to show`
451
- + (changes.length ? ' (every changed file is a protected path)' : ''), 'not_found');
544
+ throw new ExposeError(
545
+ `context.expose_diff: no change against ${base} to show` +
546
+ (changes.length ? ' (every changed file is a protected path)' : ''),
547
+ 'not_found',
548
+ );
452
549
  }
453
550
  manifest.entries.push({
454
551
  index: manifest.entries.reduce((n, e) => Math.max(n, e.index + 1), 0),
@@ -461,7 +558,9 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
461
558
  // The copies, as paths a consultant can open; the change itself, with
462
559
  // what happened to each path, is listed apart.
463
560
  paths: copied,
464
- changes: shown.map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`).sort(),
561
+ changes: shown
562
+ .map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`)
563
+ .sort(),
465
564
  sha256,
466
565
  });
467
566
 
@@ -474,18 +573,37 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
474
573
  // without its old content.
475
574
  const tracked = new Set(shown.filter((c) => c.status !== 'untracked').map((c) => c.path));
476
575
  const full = tracked.size
477
- ? git(top, ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--src-prefix=a/', '--dst-prefix=b/',
478
- '--no-relative', '--submodule=short', '--irreversible-delete', base, '--']).toString('utf8')
576
+ ? git(top, [
577
+ 'diff',
578
+ '--no-ext-diff',
579
+ '--no-textconv',
580
+ '--no-color',
581
+ '--src-prefix=a/',
582
+ '--dst-prefix=b/',
583
+ '--no-relative',
584
+ '--submodule=short',
585
+ '--irreversible-delete',
586
+ base,
587
+ '--',
588
+ ]).toString('utf8')
479
589
  : '';
480
590
  // A section whose header names no shown path (a protected one, or one git
481
591
  // had to quote) is dropped rather than guessed at.
482
- let text = redact(full.split(/^(?=diff --git )/m).filter((sec) => {
483
- const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
484
- const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
485
- return tracked.has(p) && rest === `a/${p} b/${p}`;
486
- }).join(''));
592
+ let text = redact(
593
+ full
594
+ .split(/^(?=diff --git )/m)
595
+ .filter((sec) => {
596
+ const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
597
+ const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
598
+ return tracked.has(p) && rest === `a/${p} b/${p}`;
599
+ })
600
+ .join(''),
601
+ );
487
602
  if (text.length > maxChars) {
488
- text = maxChars < 500 ? '' : `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
603
+ text =
604
+ maxChars < 500
605
+ ? ''
606
+ : `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
489
607
  }
490
608
  return text;
491
609
  }