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.
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/package.json +3 -1
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +585 -255
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +184 -25
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +578 -132
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
package/src/consultants.mjs
CHANGED
|
@@ -4,9 +4,39 @@
|
|
|
4
4
|
// schema.mjs and jobs.mjs ask it questions -- so the rules can be tested
|
|
5
5
|
// without a server.
|
|
6
6
|
|
|
7
|
+
/** @typedef {'codex' | 'claude-code' | 'antigravity' | 'opencode'} CliId */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One operator-named pairing of a CLI and a model, as built from the config.
|
|
11
|
+
* @typedef {object} Consultant
|
|
12
|
+
* @property {string} name
|
|
13
|
+
* @property {CliId} cli
|
|
14
|
+
* @property {string} model
|
|
15
|
+
* @property {string | null} effort
|
|
16
|
+
* @property {number | null} timeoutMs
|
|
17
|
+
* @property {number | null} maxBudgetUsd
|
|
18
|
+
* @property {string | null} vendor
|
|
19
|
+
* @property {boolean} enabled
|
|
20
|
+
* @property {string | null} note
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What buildConsultants makes of a config file: the consultants and ranking,
|
|
25
|
+
* and every mistake found (see buildConsultants for the problem groups).
|
|
26
|
+
* @typedef {object} BuiltConsultants
|
|
27
|
+
* @property {readonly Consultant[]} consultants
|
|
28
|
+
* @property {readonly string[]} ranking
|
|
29
|
+
* @property {string[]} problems
|
|
30
|
+
* @property {string[]} global
|
|
31
|
+
* @property {Record<string, string[]>} broken
|
|
32
|
+
* @property {Record<string, string[]>} brokenClis
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @type {readonly CliId[]} */
|
|
7
36
|
export const CLI_IDS = Object.freeze(['codex', 'claude-code', 'antigravity', 'opencode']);
|
|
8
37
|
|
|
9
38
|
// The model each CLI's consultant runs when the operator defines none.
|
|
39
|
+
/** @type {Readonly<Record<CliId, string>>} */
|
|
10
40
|
export const DEFAULT_MODELS = Object.freeze({
|
|
11
41
|
codex: 'gpt-6-astra',
|
|
12
42
|
'claude-code': 'claude-opus-5-5',
|
|
@@ -16,10 +46,15 @@ export const DEFAULT_MODELS = Object.freeze({
|
|
|
16
46
|
|
|
17
47
|
// The lineage a CLI's own host model belongs to, for a caller that declared no
|
|
18
48
|
// model. OpenCode runs anyone's models, so it has none.
|
|
49
|
+
/** @type {Readonly<Record<CliId, string | null>>} */
|
|
19
50
|
export const CLI_VENDORS = Object.freeze({ codex: 'openai', 'claude-code': 'anthropic', antigravity: 'google', opencode: null });
|
|
20
51
|
|
|
21
52
|
// Null-prototype tables, so a request cannot reach Object.prototype through
|
|
22
53
|
// them: 'constructor' must be no match, not a function.
|
|
54
|
+
/**
|
|
55
|
+
* @param {Record<string, string>} entries
|
|
56
|
+
* @returns {Readonly<Record<string, string>>}
|
|
57
|
+
*/
|
|
23
58
|
const table = (entries) => Object.freeze(Object.assign(Object.create(null), entries));
|
|
24
59
|
|
|
25
60
|
// Names that mean "the consultants on this CLI". gpt/claude/gemini/glm are
|
|
@@ -49,7 +84,10 @@ const STEM_VENDORS = table({
|
|
|
49
84
|
// Built-in "higher first" orders, each used only inside one model family: an
|
|
50
85
|
// "ultra" Nemotron is not above a "flash" DeepSeek. `family` says which
|
|
51
86
|
// candidates a list may compare; a family not listed here is never guessed at.
|
|
87
|
+
/** @param {readonly Consultant[]} cands */
|
|
52
88
|
const sameStem = (cands) => new Set(cands.map((c) => modelStem(c.model).join('-'))).size === 1;
|
|
89
|
+
/** @typedef {{ words: string[], family: (cands: readonly Consultant[]) => boolean, last?: boolean }} Tier */
|
|
90
|
+
/** @type {readonly Tier[]} */
|
|
53
91
|
const TIERS = Object.freeze([
|
|
54
92
|
{ words: ['opus', 'sonnet', 'haiku'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'claude') },
|
|
55
93
|
{ words: ['ultra', 'pro', 'flash', 'lite'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'gemini') },
|
|
@@ -59,7 +97,12 @@ const TIERS = Object.freeze([
|
|
|
59
97
|
|
|
60
98
|
export const RESERVED_NAMES = Object.freeze(['all']);
|
|
61
99
|
|
|
62
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code".
|
|
102
|
+
* @template T
|
|
103
|
+
* @param {T} raw
|
|
104
|
+
* @returns {T | string}
|
|
105
|
+
*/
|
|
63
106
|
export function normalizeName(raw) {
|
|
64
107
|
if (typeof raw !== 'string') return raw;
|
|
65
108
|
return raw
|
|
@@ -68,7 +111,11 @@ export function normalizeName(raw) {
|
|
|
68
111
|
.replace(/[\s_]+/g, '-');
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model.
|
|
116
|
+
* @param {unknown} model
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
72
119
|
export function modelWords(model) {
|
|
73
120
|
const s = String(model ?? '').toLowerCase();
|
|
74
121
|
return s
|
|
@@ -77,26 +124,42 @@ export function modelWords(model) {
|
|
|
77
124
|
.filter(Boolean);
|
|
78
125
|
}
|
|
79
126
|
|
|
127
|
+
/** @param {string} w */
|
|
80
128
|
const isDigitWord = (w) => /^v?\d+$/.test(w);
|
|
81
129
|
|
|
82
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The words before the version: "claude-opus-5-5" -> ["claude", "opus"].
|
|
132
|
+
* @param {unknown} model
|
|
133
|
+
*/
|
|
83
134
|
export function modelStem(model) {
|
|
84
135
|
const words = modelWords(model);
|
|
85
136
|
const at = words.findIndex(isDigitWord);
|
|
86
137
|
return at === -1 ? words : words.slice(0, at);
|
|
87
138
|
}
|
|
88
139
|
|
|
89
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none.
|
|
142
|
+
* @param {unknown} model
|
|
143
|
+
* @returns {number[] | null}
|
|
144
|
+
*/
|
|
90
145
|
export function modelVersion(model) {
|
|
91
146
|
const words = modelWords(model);
|
|
92
147
|
const at = words.findIndex(isDigitWord);
|
|
93
148
|
if (at === -1) return null;
|
|
94
149
|
const digits = [];
|
|
95
|
-
for (
|
|
150
|
+
for (const w of words.slice(at)) {
|
|
151
|
+
if (!isDigitWord(w)) break;
|
|
152
|
+
digits.push(Number(w.replace(/^v/, '')));
|
|
153
|
+
}
|
|
96
154
|
return digits;
|
|
97
155
|
}
|
|
98
156
|
|
|
99
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess.
|
|
159
|
+
* @param {unknown} model
|
|
160
|
+
* @param {unknown} [explicit]
|
|
161
|
+
* @returns {string | null}
|
|
162
|
+
*/
|
|
100
163
|
export function vendorOf(model, explicit = null) {
|
|
101
164
|
if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim().toLowerCase();
|
|
102
165
|
const first = modelStem(model)[0] ?? '';
|
|
@@ -104,13 +167,25 @@ export function vendorOf(model, explicit = null) {
|
|
|
104
167
|
return STEM_VENDORS[first] ?? null;
|
|
105
168
|
}
|
|
106
169
|
|
|
170
|
+
/**
|
|
171
|
+
* @param {string[]} hay
|
|
172
|
+
* @param {string[]} needle
|
|
173
|
+
*/
|
|
107
174
|
const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
|
|
108
175
|
|
|
109
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no.
|
|
178
|
+
* @param {unknown} model
|
|
179
|
+
* @param {unknown} wanted
|
|
180
|
+
*/
|
|
110
181
|
export function matchesWords(model, wanted) {
|
|
111
182
|
return containsRun(modelWords(model), modelWords(wanted));
|
|
112
183
|
}
|
|
113
184
|
|
|
185
|
+
/**
|
|
186
|
+
* @param {number[]} a
|
|
187
|
+
* @param {number[]} b
|
|
188
|
+
*/
|
|
114
189
|
const compareVersions = (a, b) => {
|
|
115
190
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
116
191
|
const d = (a[i] ?? -1) - (b[i] ?? -1);
|
|
@@ -119,7 +194,12 @@ const compareVersions = (a, b) => {
|
|
|
119
194
|
return 0;
|
|
120
195
|
};
|
|
121
196
|
|
|
197
|
+
/**
|
|
198
|
+
* @param {readonly Consultant[]} cands
|
|
199
|
+
* @param {readonly string[]} ranking
|
|
200
|
+
*/
|
|
122
201
|
function byRanking(cands, ranking) {
|
|
202
|
+
/** @param {Consultant} c */
|
|
123
203
|
const index = (c) => {
|
|
124
204
|
const i = ranking.findIndex((r) => normalizeName(r) === normalizeName(c.name) || matchesWords(c.model, r));
|
|
125
205
|
return i === -1 ? Infinity : i;
|
|
@@ -128,11 +208,18 @@ function byRanking(cands, ranking) {
|
|
|
128
208
|
return best === Infinity ? cands : cands.filter((c) => index(c) === best);
|
|
129
209
|
}
|
|
130
210
|
|
|
211
|
+
/**
|
|
212
|
+
* @param {readonly Consultant[]} cands
|
|
213
|
+
* @param {Tier} tier
|
|
214
|
+
*/
|
|
131
215
|
function byTier(cands, tier) {
|
|
132
216
|
if (!tier.family(cands)) return cands;
|
|
217
|
+
/** @param {Consultant} c */
|
|
133
218
|
const rank = (c) => {
|
|
134
219
|
const words = modelWords(c.model);
|
|
135
|
-
|
|
220
|
+
const last = words.at(-1);
|
|
221
|
+
if (tier.last) return last === undefined ? -1 : tier.words.indexOf(last);
|
|
222
|
+
return tier.words.findIndex((w) => words.includes(w));
|
|
136
223
|
};
|
|
137
224
|
const ranks = cands.map(rank);
|
|
138
225
|
if (ranks.includes(-1) || new Set(ranks).size === 1) return cands;
|
|
@@ -140,18 +227,29 @@ function byTier(cands, tier) {
|
|
|
140
227
|
return cands.filter((_, i) => ranks[i] === best);
|
|
141
228
|
}
|
|
142
229
|
|
|
230
|
+
/** @param {readonly Consultant[]} cands */
|
|
143
231
|
function byVersion(cands) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
232
|
+
const [first] = cands;
|
|
233
|
+
if (!first) return cands;
|
|
234
|
+
const stem = modelStem(first.model).join('-');
|
|
235
|
+
if (cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
|
|
236
|
+
const versioned = [];
|
|
237
|
+
for (const c of cands) {
|
|
238
|
+
const version = modelVersion(c.model);
|
|
239
|
+
if (version === null) return cands;
|
|
240
|
+
versioned.push({ c, version });
|
|
241
|
+
}
|
|
242
|
+
const best = versioned.map((x) => x.version).reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
|
|
243
|
+
return versioned.filter((x) => compareVersions(x.version, best) === 0).map((x) => x.c);
|
|
149
244
|
}
|
|
150
245
|
|
|
151
246
|
/**
|
|
152
247
|
* One of several candidates, by the operator's ranking, then the built-in
|
|
153
248
|
* tiers, then the version within one stem -- or null when none of those
|
|
154
249
|
* separates them. Never a guess: two names nothing orders stay two.
|
|
250
|
+
* @param {readonly Consultant[]} cands
|
|
251
|
+
* @param {readonly string[]} [ranking]
|
|
252
|
+
* @returns {Consultant | null}
|
|
155
253
|
*/
|
|
156
254
|
export function rankCandidates(cands, ranking = []) {
|
|
157
255
|
let left = byRanking(cands, ranking);
|
|
@@ -160,13 +258,19 @@ export function rankCandidates(cands, ranking = []) {
|
|
|
160
258
|
left = byTier(left, tier);
|
|
161
259
|
}
|
|
162
260
|
if (left.length > 1) left = byVersion(left);
|
|
163
|
-
return left.length === 1 ? left[0] : null;
|
|
261
|
+
return left.length === 1 ? (left[0] ?? null) : null;
|
|
164
262
|
}
|
|
165
263
|
|
|
264
|
+
/** @typedef {{ none: true, consultant?: undefined, ambiguous?: undefined } | { none?: undefined, consultant: Consultant, ambiguous?: undefined } | { none?: undefined, consultant?: undefined, ambiguous: readonly Consultant[] }} Resolution */
|
|
265
|
+
|
|
166
266
|
/**
|
|
167
267
|
* What a requested name means: a consultant's own name, then a CLI name, then
|
|
168
268
|
* a vendor word, then the words of a model id. Several candidates are ranked;
|
|
169
269
|
* ones that cannot be ranked come back as `ambiguous`, never as a pick.
|
|
270
|
+
* @param {unknown} input
|
|
271
|
+
* @param {readonly Consultant[]} consultants
|
|
272
|
+
* @param {readonly string[]} [ranking]
|
|
273
|
+
* @returns {Resolution}
|
|
170
274
|
*/
|
|
171
275
|
export function resolveConsultant(input, consultants, ranking = []) {
|
|
172
276
|
const key = normalizeName(input);
|
|
@@ -177,8 +281,9 @@ export function resolveConsultant(input, consultants, ranking = []) {
|
|
|
177
281
|
if (CLI_ALIASES[key]) cands = consultants.filter((c) => c.cli === CLI_ALIASES[key]);
|
|
178
282
|
else if (VENDOR_WORDS[key]) cands = consultants.filter((c) => c.vendor === VENDOR_WORDS[key]);
|
|
179
283
|
else cands = consultants.filter((c) => matchesWords(c.model, input));
|
|
180
|
-
|
|
181
|
-
if (
|
|
284
|
+
const [only] = cands;
|
|
285
|
+
if (!only) return { none: true };
|
|
286
|
+
if (cands.length === 1) return { consultant: only };
|
|
182
287
|
const winner = rankCandidates(cands, ranking);
|
|
183
288
|
return winner ? { consultant: winner } : { ambiguous: cands };
|
|
184
289
|
}
|
|
@@ -188,13 +293,23 @@ export function resolveConsultant(input, consultants, ranking = []) {
|
|
|
188
293
|
* CLI is checked once, through its first available consultant -- and a CLI
|
|
189
294
|
* name asks for exactly that one, never an ambiguity between its consultants.
|
|
190
295
|
* Any other name resolves like a request. null when nothing answers to it.
|
|
296
|
+
* @param {unknown} arg
|
|
297
|
+
* @param {readonly Consultant[]} available
|
|
298
|
+
* @param {readonly Consultant[]} all
|
|
299
|
+
* @param {readonly string[]} [ranking]
|
|
300
|
+
* @returns {Consultant[] | null}
|
|
191
301
|
*/
|
|
192
302
|
export function checkTargets(arg, available, all, ranking = []) {
|
|
303
|
+
/** @type {Map<string, Consultant>} */
|
|
193
304
|
const first = new Map();
|
|
194
305
|
for (const c of available) if (!first.has(c.cli)) first.set(c.cli, c);
|
|
195
306
|
if (arg === null || arg === undefined) return [...first.values()];
|
|
196
|
-
const
|
|
197
|
-
|
|
307
|
+
const key = normalizeName(arg);
|
|
308
|
+
const cli = typeof key === 'string' ? CLI_ALIASES[key] : undefined;
|
|
309
|
+
if (cli) {
|
|
310
|
+
const hit = first.get(cli);
|
|
311
|
+
return hit ? [hit] : null;
|
|
312
|
+
}
|
|
198
313
|
const r = resolveConsultant(arg, all, ranking);
|
|
199
314
|
return r.consultant ? [r.consultant] : null;
|
|
200
315
|
}
|
|
@@ -204,6 +319,9 @@ export function checkTargets(arg, available, all, ranking = []) {
|
|
|
204
319
|
* the words agree exactly; one being a prefix-like part of the other
|
|
205
320
|
* ("claude-opus-5" in "claude-opus-5-5") is "unknown", because saying either
|
|
206
321
|
* "same" or "different" there would be a claim the server cannot back.
|
|
322
|
+
* @param {unknown} callerModel
|
|
323
|
+
* @param {unknown} consultantModel
|
|
324
|
+
* @returns {'same' | 'unknown' | 'different'}
|
|
207
325
|
*/
|
|
208
326
|
export function sameModel(callerModel, consultantModel) {
|
|
209
327
|
const a = modelWords(callerModel);
|
|
@@ -216,7 +334,16 @@ export function sameModel(callerModel, consultantModel) {
|
|
|
216
334
|
const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
|
|
217
335
|
const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
|
|
218
336
|
const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'vendor', 'enabled', 'note'];
|
|
219
|
-
|
|
337
|
+
/**
|
|
338
|
+
* @param {unknown} v
|
|
339
|
+
* @returns {v is Record<string, unknown>}
|
|
340
|
+
*/
|
|
341
|
+
export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
342
|
+
/**
|
|
343
|
+
* @param {number} v
|
|
344
|
+
* @param {number} min
|
|
345
|
+
* @param {number} max
|
|
346
|
+
*/
|
|
220
347
|
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
221
348
|
|
|
222
349
|
/**
|
|
@@ -224,6 +351,8 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
|
224
351
|
* as a problem string. A config without `consultants` gets one consultant per
|
|
225
352
|
* CLI, named after it, on that CLI's default model; whether it can run is the
|
|
226
353
|
* CLI's availability, decided by policy.mjs.
|
|
354
|
+
* @param {unknown} config the parsed config file, before validation
|
|
355
|
+
* @returns {BuiltConsultants}
|
|
227
356
|
*/
|
|
228
357
|
export function buildConsultants(config) {
|
|
229
358
|
// `problems` is every mistake, for the operator to read; `global` the ones
|
|
@@ -231,10 +360,15 @@ export function buildConsultants(config) {
|
|
|
231
360
|
// or one CLI is kept to it: that consultant cannot be used (`broken`), that
|
|
232
361
|
// CLI is off (`brokenClis`) -- the closed side either way, so nothing the
|
|
233
362
|
// operator turned off comes back on.
|
|
363
|
+
/** @type {string[]} */
|
|
234
364
|
const problems = [];
|
|
365
|
+
/** @type {string[]} */
|
|
235
366
|
const global = [];
|
|
367
|
+
/** @type {Record<string, string[]>} */
|
|
236
368
|
const broken = {};
|
|
369
|
+
/** @type {Record<string, string[]>} */
|
|
237
370
|
const brokenClis = {};
|
|
371
|
+
/** @param {string} msg */
|
|
238
372
|
const fault = (msg) => {
|
|
239
373
|
problems.push(msg);
|
|
240
374
|
global.push(msg);
|
|
@@ -247,7 +381,9 @@ export function buildConsultants(config) {
|
|
|
247
381
|
|
|
248
382
|
// The one server key that is not a number to clamp: a wrong value cannot
|
|
249
383
|
// be read as either setting, and reading it as "on" would open the web.
|
|
250
|
-
|
|
384
|
+
/** @type {readonly unknown[]} */
|
|
385
|
+
const networkModes = ['on', 'off'];
|
|
386
|
+
if (isObject(cfg.server) && cfg.server.network !== undefined && !networkModes.includes(cfg.server.network)) {
|
|
251
387
|
fault('server.network must be "on" or "off"');
|
|
252
388
|
}
|
|
253
389
|
|
|
@@ -258,11 +394,13 @@ export function buildConsultants(config) {
|
|
|
258
394
|
else fault('ranking must be an array of non-empty strings');
|
|
259
395
|
}
|
|
260
396
|
|
|
397
|
+
/** @type {readonly unknown[]} */
|
|
398
|
+
const cliIds = CLI_IDS;
|
|
261
399
|
if (cfg.clis !== undefined) {
|
|
262
400
|
if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
|
|
263
401
|
else {
|
|
264
402
|
for (const [id, def] of Object.entries(cfg.clis)) {
|
|
265
|
-
if (!
|
|
403
|
+
if (!cliIds.includes(id)) {
|
|
266
404
|
problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
|
|
267
405
|
continue;
|
|
268
406
|
}
|
|
@@ -294,6 +432,7 @@ export function buildConsultants(config) {
|
|
|
294
432
|
}
|
|
295
433
|
}
|
|
296
434
|
|
|
435
|
+
/** @type {Consultant[]} */
|
|
297
436
|
const consultants = [];
|
|
298
437
|
if (cfg.consultants === undefined) {
|
|
299
438
|
for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
|
|
@@ -319,7 +458,7 @@ export function buildConsultants(config) {
|
|
|
319
458
|
}
|
|
320
459
|
for (const k of Object.keys(def))
|
|
321
460
|
if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
|
|
322
|
-
if (!
|
|
461
|
+
if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
|
|
323
462
|
if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
|
|
324
463
|
for (const k of ['effort', 'vendor', 'note']) {
|
|
325
464
|
if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
|
|
@@ -338,19 +477,39 @@ export function buildConsultants(config) {
|
|
|
338
477
|
broken[name] = problems.slice(before);
|
|
339
478
|
continue;
|
|
340
479
|
}
|
|
341
|
-
|
|
480
|
+
// The checks above have all passed: cli is a known id and model a non-empty string.
|
|
481
|
+
const valid = /** @type {ConsultantDef} */ (def);
|
|
482
|
+
const pair = `${valid.cli}\n${valid.model.trim()}`;
|
|
342
483
|
if (seen.has(pair)) {
|
|
343
484
|
problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
|
|
344
485
|
broken[name] = problems.slice(before);
|
|
345
486
|
continue;
|
|
346
487
|
}
|
|
347
488
|
seen.set(pair, name);
|
|
348
|
-
consultants.push(make(name,
|
|
489
|
+
consultants.push(make(name, valid));
|
|
349
490
|
}
|
|
350
491
|
}
|
|
351
492
|
return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
|
|
352
493
|
}
|
|
353
494
|
|
|
495
|
+
/**
|
|
496
|
+
* A consultant's definition in the config file, once it has passed validation.
|
|
497
|
+
* @typedef {object} ConsultantDef
|
|
498
|
+
* @property {CliId} cli
|
|
499
|
+
* @property {string} model
|
|
500
|
+
* @property {unknown} [effort]
|
|
501
|
+
* @property {unknown} [timeout_ms]
|
|
502
|
+
* @property {unknown} [max_budget_usd]
|
|
503
|
+
* @property {unknown} [vendor]
|
|
504
|
+
* @property {unknown} [enabled]
|
|
505
|
+
* @property {unknown} [note]
|
|
506
|
+
*/
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* @param {string} name
|
|
510
|
+
* @param {ConsultantDef} def a definition that has passed validation
|
|
511
|
+
* @returns {Consultant}
|
|
512
|
+
*/
|
|
354
513
|
function make(name, def) {
|
|
355
514
|
const model = def.model.trim();
|
|
356
515
|
return Object.freeze({
|
|
@@ -360,7 +519,7 @@ function make(name, def) {
|
|
|
360
519
|
effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
|
|
361
520
|
timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
|
|
362
521
|
maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
|
|
363
|
-
vendor: vendorOf(model, def.vendor
|
|
522
|
+
vendor: vendorOf(model, def.vendor),
|
|
364
523
|
enabled: def.enabled !== false,
|
|
365
524
|
note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
|
|
366
525
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// cross-spawn ships no types for its internal escaping helpers.
|
|
2
|
+
declare module 'cross-spawn/lib/util/escape.js' {
|
|
3
|
+
export function command(arg: string): string;
|
|
4
|
+
export function argument(arg: string, doubleEscape?: boolean): string;
|
|
5
|
+
const escape: { command: typeof command; argument: typeof argument };
|
|
6
|
+
export default escape;
|
|
7
|
+
}
|
package/src/export.mjs
CHANGED
|
@@ -13,7 +13,15 @@ import path from 'node:path';
|
|
|
13
13
|
import { POLICY } from './policy.mjs';
|
|
14
14
|
import { humanSize } from './expose-paths.mjs';
|
|
15
15
|
|
|
16
|
+
/** @typedef {import('./store.mjs').Round} Round A round parsed from the history files. */
|
|
17
|
+
/** @typedef {import('./store.mjs').BriefRecord} BriefRecord */
|
|
18
|
+
/** @typedef {import('./store.mjs').JobRecord} JobRecord */
|
|
19
|
+
|
|
16
20
|
export class ExportError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} message
|
|
23
|
+
* @param {string} code
|
|
24
|
+
*/
|
|
17
25
|
constructor(message, code) {
|
|
18
26
|
super(message);
|
|
19
27
|
this.name = 'ExportError';
|
|
@@ -23,6 +31,7 @@ export class ExportError extends Error {
|
|
|
23
31
|
|
|
24
32
|
const historyDir = () => path.join(POLICY.home, 'history');
|
|
25
33
|
|
|
34
|
+
/** @param {string} p */
|
|
26
35
|
function readJson(p) {
|
|
27
36
|
try {
|
|
28
37
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
@@ -31,8 +40,13 @@ function readJson(p) {
|
|
|
31
40
|
}
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} chainId
|
|
45
|
+
* @returns {Round[]}
|
|
46
|
+
*/
|
|
34
47
|
function roundsOf(chainId) {
|
|
35
48
|
const dir = path.join(historyDir(), chainId);
|
|
49
|
+
/** @type {string[]} */
|
|
36
50
|
let names = [];
|
|
37
51
|
try {
|
|
38
52
|
names = fs.readdirSync(dir);
|
|
@@ -49,6 +63,10 @@ function roundsOf(chainId) {
|
|
|
49
63
|
// A fan-out is one question sent to several consultants, and each consultant
|
|
50
64
|
// answers on its own chain. Nothing in the group file records which chain that
|
|
51
65
|
// was, so the index -- one line per consultation -- is what maps them back.
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} groupId
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
52
70
|
function chainsOfGroup(groupId) {
|
|
53
71
|
const group = readJson(path.join(historyDir(), 'groups', `${groupId}.json`));
|
|
54
72
|
if (!group) throw new ExportError(`no fan-out with group_id "${groupId}" in ~/.severally/history`, 'unknown_group');
|
|
@@ -62,6 +80,7 @@ function chainsOfGroup(groupId) {
|
|
|
62
80
|
} catch {
|
|
63
81
|
/* an unreadable index leaves the group unresolvable, handled below */
|
|
64
82
|
}
|
|
83
|
+
/** @type {string[]} */
|
|
65
84
|
const chains = [];
|
|
66
85
|
for (const jobId of group.job_ids ?? []) {
|
|
67
86
|
const chainId = byJob.get(jobId);
|
|
@@ -73,16 +92,30 @@ function chainsOfGroup(groupId) {
|
|
|
73
92
|
return chains;
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
/**
|
|
96
|
+
* @param {string[] | null | undefined} items
|
|
97
|
+
* @param {(item: string) => string} render
|
|
98
|
+
*/
|
|
76
99
|
const bullets = (items, render) => (items ?? []).map(render);
|
|
77
100
|
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} text
|
|
103
|
+
* @param {string | null | undefined} language
|
|
104
|
+
*/
|
|
78
105
|
function fence(text, language) {
|
|
79
106
|
const marker = String(text).includes('```') ? '~~~' : '```';
|
|
80
107
|
return [`${marker}${language ?? ''}`, text, marker];
|
|
81
108
|
}
|
|
82
109
|
|
|
110
|
+
/** @param {BriefRecord | null | undefined} brief */
|
|
83
111
|
function briefSection(brief) {
|
|
84
112
|
if (!brief) return ['*(the brief was not recorded for this round)*', ''];
|
|
113
|
+
/** @type {string[]} */
|
|
85
114
|
const out = [];
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} title
|
|
117
|
+
* @param {string | string[] | null | undefined} body
|
|
118
|
+
*/
|
|
86
119
|
const block = (title, body) => {
|
|
87
120
|
if (!body || (Array.isArray(body) && body.length === 0)) return;
|
|
88
121
|
out.push(`**${title}**`, '');
|
|
@@ -128,6 +161,7 @@ function briefSection(brief) {
|
|
|
128
161
|
: ', unchanged since the earlier round';
|
|
129
162
|
out.push(`- ${e.exposed_as} (${e.kind}, ${e.files} file(s), ${humanSize(e.bytes)}${since}) -- from ${e.source}`);
|
|
130
163
|
}
|
|
164
|
+
/** @param {string} reason */
|
|
131
165
|
const count = (reason) => (exposed.skipped ?? []).filter((x) => x.reason === reason).length;
|
|
132
166
|
if (count('symlink')) out.push(`- ${count('symlink')} symlink(s) inside those directories were skipped, not followed`);
|
|
133
167
|
if (count('protected')) out.push(`- ${count('protected')} credential or history path(s) were left out on purpose`);
|
|
@@ -143,6 +177,10 @@ function briefSection(brief) {
|
|
|
143
177
|
// The lead's own line, printed under the point it belongs to. An id with no
|
|
144
178
|
// entry says so out loud: "nobody looked at this" is the thing a record of a
|
|
145
179
|
// decision most easily loses.
|
|
180
|
+
/**
|
|
181
|
+
* @param {JobRecord | null | undefined} record
|
|
182
|
+
* @param {string} id
|
|
183
|
+
*/
|
|
146
184
|
function verdictLine(record, id) {
|
|
147
185
|
const entry = (record?.entries ?? []).find((e) => e.id === id);
|
|
148
186
|
if (!entry) return ' - checked: no verdict recorded';
|
|
@@ -150,6 +188,7 @@ function verdictLine(record, id) {
|
|
|
150
188
|
return ` - checked: ${entry.verdict}${tail ? ` -- ${tail}` : ''}`;
|
|
151
189
|
}
|
|
152
190
|
|
|
191
|
+
/** @param {Round} round */
|
|
153
192
|
function answerSection(round) {
|
|
154
193
|
const r = round.result;
|
|
155
194
|
if (!r) {
|
|
@@ -160,6 +199,7 @@ function answerSection(round) {
|
|
|
160
199
|
'',
|
|
161
200
|
];
|
|
162
201
|
}
|
|
202
|
+
/** @type {string[]} */
|
|
163
203
|
const out = [];
|
|
164
204
|
const record = round.record;
|
|
165
205
|
out.push('**Summary**', '', r.summary, '');
|
|
@@ -227,6 +267,10 @@ function answerSection(round) {
|
|
|
227
267
|
// A fan-out sends the byte-identical brief to every consultant, so printing it
|
|
228
268
|
// once per consultant buries the answers in repetition. Compared, not judged:
|
|
229
269
|
// identical text is named as identical, anything else is printed in full.
|
|
270
|
+
/**
|
|
271
|
+
* @param {Round} round
|
|
272
|
+
* @param {BriefRecord | null} previousBrief
|
|
273
|
+
*/
|
|
230
274
|
function roundSection(round, previousBrief) {
|
|
231
275
|
const secs = round.duration_ms == null ? null : (round.duration_ms / 1000).toFixed(1);
|
|
232
276
|
const head = [
|
|
@@ -269,21 +313,20 @@ function roundSection(round, previousBrief) {
|
|
|
269
313
|
}
|
|
270
314
|
|
|
271
315
|
/**
|
|
272
|
-
* @param {{chain_id?: string, group_id?: string}} target
|
|
316
|
+
* @param {{chain_id?: string | undefined, group_id?: string | undefined}} target
|
|
273
317
|
* @returns {{markdown: string, rounds: number, chain_ids: string[]}}
|
|
274
318
|
*/
|
|
275
319
|
export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
|
|
276
320
|
if (Boolean(chainId) === Boolean(groupId)) {
|
|
277
321
|
throw new ExportError('pass exactly one of chain_id or group_id', 'export_target_required');
|
|
278
322
|
}
|
|
279
|
-
const chainIds = groupId ? chainsOfGroup(groupId) : [chainId];
|
|
323
|
+
const chainIds = groupId ? chainsOfGroup(groupId) : chainId ? [chainId] : [];
|
|
280
324
|
const perChain = chainIds.map((id) => ({ chain_id: id, rounds: roundsOf(id) }));
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
325
|
+
const first = perChain.find((c) => c.rounds.length)?.rounds[0];
|
|
326
|
+
if (!first) {
|
|
283
327
|
throw new ExportError(`no consultation recorded under "${chainId ?? groupId}" in ~/.severally/history`, 'unknown_chain');
|
|
284
328
|
}
|
|
285
|
-
|
|
286
|
-
const first = perChain.find((c) => c.rounds.length)?.rounds[0];
|
|
329
|
+
const total = perChain.reduce((n, c) => n + c.rounds.length, 0);
|
|
287
330
|
const lines = [
|
|
288
331
|
`# Consultation record: ${first.question}`,
|
|
289
332
|
'',
|
|
@@ -296,6 +339,7 @@ export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
|
|
|
296
339
|
'ways, both are printed and the difference is left standing.',
|
|
297
340
|
'',
|
|
298
341
|
];
|
|
342
|
+
/** @type {BriefRecord | null} */
|
|
299
343
|
let previousBrief = null;
|
|
300
344
|
for (const chain of perChain) {
|
|
301
345
|
for (const round of chain.rounds) {
|