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.
- package/CONFIG.md +1 -0
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- 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 +630 -271
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/init-config.mjs +2 -1
- 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 +193 -27
- 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 +632 -152
- 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,40 @@
|
|
|
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} fallbackModel
|
|
19
|
+
* @property {string | null} vendor
|
|
20
|
+
* @property {boolean} enabled
|
|
21
|
+
* @property {string | null} note
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What buildConsultants makes of a config file: the consultants and ranking,
|
|
26
|
+
* and every mistake found (see buildConsultants for the problem groups).
|
|
27
|
+
* @typedef {object} BuiltConsultants
|
|
28
|
+
* @property {readonly Consultant[]} consultants
|
|
29
|
+
* @property {readonly string[]} ranking
|
|
30
|
+
* @property {string[]} problems
|
|
31
|
+
* @property {string[]} global
|
|
32
|
+
* @property {Record<string, string[]>} broken
|
|
33
|
+
* @property {Record<string, string[]>} brokenClis
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @type {readonly CliId[]} */
|
|
7
37
|
export const CLI_IDS = Object.freeze(['codex', 'claude-code', 'antigravity', 'opencode']);
|
|
8
38
|
|
|
9
39
|
// The model each CLI's consultant runs when the operator defines none.
|
|
40
|
+
/** @type {Readonly<Record<CliId, string>>} */
|
|
10
41
|
export const DEFAULT_MODELS = Object.freeze({
|
|
11
42
|
codex: 'gpt-6-astra',
|
|
12
43
|
'claude-code': 'claude-opus-5-5',
|
|
@@ -16,10 +47,15 @@ export const DEFAULT_MODELS = Object.freeze({
|
|
|
16
47
|
|
|
17
48
|
// The lineage a CLI's own host model belongs to, for a caller that declared no
|
|
18
49
|
// model. OpenCode runs anyone's models, so it has none.
|
|
50
|
+
/** @type {Readonly<Record<CliId, string | null>>} */
|
|
19
51
|
export const CLI_VENDORS = Object.freeze({ codex: 'openai', 'claude-code': 'anthropic', antigravity: 'google', opencode: null });
|
|
20
52
|
|
|
21
53
|
// Null-prototype tables, so a request cannot reach Object.prototype through
|
|
22
54
|
// them: 'constructor' must be no match, not a function.
|
|
55
|
+
/**
|
|
56
|
+
* @param {Record<string, string>} entries
|
|
57
|
+
* @returns {Readonly<Record<string, string>>}
|
|
58
|
+
*/
|
|
23
59
|
const table = (entries) => Object.freeze(Object.assign(Object.create(null), entries));
|
|
24
60
|
|
|
25
61
|
// Names that mean "the consultants on this CLI". gpt/claude/gemini/glm are
|
|
@@ -49,7 +85,10 @@ const STEM_VENDORS = table({
|
|
|
49
85
|
// Built-in "higher first" orders, each used only inside one model family: an
|
|
50
86
|
// "ultra" Nemotron is not above a "flash" DeepSeek. `family` says which
|
|
51
87
|
// candidates a list may compare; a family not listed here is never guessed at.
|
|
88
|
+
/** @param {readonly Consultant[]} cands */
|
|
52
89
|
const sameStem = (cands) => new Set(cands.map((c) => modelStem(c.model).join('-'))).size === 1;
|
|
90
|
+
/** @typedef {{ words: string[], family: (cands: readonly Consultant[]) => boolean, last?: boolean }} Tier */
|
|
91
|
+
/** @type {readonly Tier[]} */
|
|
53
92
|
const TIERS = Object.freeze([
|
|
54
93
|
{ words: ['opus', 'sonnet', 'haiku'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'claude') },
|
|
55
94
|
{ words: ['ultra', 'pro', 'flash', 'lite'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'gemini') },
|
|
@@ -59,7 +98,12 @@ const TIERS = Object.freeze([
|
|
|
59
98
|
|
|
60
99
|
export const RESERVED_NAMES = Object.freeze(['all']);
|
|
61
100
|
|
|
62
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code".
|
|
103
|
+
* @template T
|
|
104
|
+
* @param {T} raw
|
|
105
|
+
* @returns {T | string}
|
|
106
|
+
*/
|
|
63
107
|
export function normalizeName(raw) {
|
|
64
108
|
if (typeof raw !== 'string') return raw;
|
|
65
109
|
return raw
|
|
@@ -68,7 +112,11 @@ export function normalizeName(raw) {
|
|
|
68
112
|
.replace(/[\s_]+/g, '-');
|
|
69
113
|
}
|
|
70
114
|
|
|
71
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model.
|
|
117
|
+
* @param {unknown} model
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
72
120
|
export function modelWords(model) {
|
|
73
121
|
const s = String(model ?? '').toLowerCase();
|
|
74
122
|
return s
|
|
@@ -77,26 +125,42 @@ export function modelWords(model) {
|
|
|
77
125
|
.filter(Boolean);
|
|
78
126
|
}
|
|
79
127
|
|
|
128
|
+
/** @param {string} w */
|
|
80
129
|
const isDigitWord = (w) => /^v?\d+$/.test(w);
|
|
81
130
|
|
|
82
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* The words before the version: "claude-opus-5-5" -> ["claude", "opus"].
|
|
133
|
+
* @param {unknown} model
|
|
134
|
+
*/
|
|
83
135
|
export function modelStem(model) {
|
|
84
136
|
const words = modelWords(model);
|
|
85
137
|
const at = words.findIndex(isDigitWord);
|
|
86
138
|
return at === -1 ? words : words.slice(0, at);
|
|
87
139
|
}
|
|
88
140
|
|
|
89
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none.
|
|
143
|
+
* @param {unknown} model
|
|
144
|
+
* @returns {number[] | null}
|
|
145
|
+
*/
|
|
90
146
|
export function modelVersion(model) {
|
|
91
147
|
const words = modelWords(model);
|
|
92
148
|
const at = words.findIndex(isDigitWord);
|
|
93
149
|
if (at === -1) return null;
|
|
94
150
|
const digits = [];
|
|
95
|
-
for (
|
|
151
|
+
for (const w of words.slice(at)) {
|
|
152
|
+
if (!isDigitWord(w)) break;
|
|
153
|
+
digits.push(Number(w.replace(/^v/, '')));
|
|
154
|
+
}
|
|
96
155
|
return digits;
|
|
97
156
|
}
|
|
98
157
|
|
|
99
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess.
|
|
160
|
+
* @param {unknown} model
|
|
161
|
+
* @param {unknown} [explicit]
|
|
162
|
+
* @returns {string | null}
|
|
163
|
+
*/
|
|
100
164
|
export function vendorOf(model, explicit = null) {
|
|
101
165
|
if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim().toLowerCase();
|
|
102
166
|
const first = modelStem(model)[0] ?? '';
|
|
@@ -104,13 +168,25 @@ export function vendorOf(model, explicit = null) {
|
|
|
104
168
|
return STEM_VENDORS[first] ?? null;
|
|
105
169
|
}
|
|
106
170
|
|
|
171
|
+
/**
|
|
172
|
+
* @param {string[]} hay
|
|
173
|
+
* @param {string[]} needle
|
|
174
|
+
*/
|
|
107
175
|
const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
|
|
108
176
|
|
|
109
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no.
|
|
179
|
+
* @param {unknown} model
|
|
180
|
+
* @param {unknown} wanted
|
|
181
|
+
*/
|
|
110
182
|
export function matchesWords(model, wanted) {
|
|
111
183
|
return containsRun(modelWords(model), modelWords(wanted));
|
|
112
184
|
}
|
|
113
185
|
|
|
186
|
+
/**
|
|
187
|
+
* @param {number[]} a
|
|
188
|
+
* @param {number[]} b
|
|
189
|
+
*/
|
|
114
190
|
const compareVersions = (a, b) => {
|
|
115
191
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
116
192
|
const d = (a[i] ?? -1) - (b[i] ?? -1);
|
|
@@ -119,7 +195,12 @@ const compareVersions = (a, b) => {
|
|
|
119
195
|
return 0;
|
|
120
196
|
};
|
|
121
197
|
|
|
198
|
+
/**
|
|
199
|
+
* @param {readonly Consultant[]} cands
|
|
200
|
+
* @param {readonly string[]} ranking
|
|
201
|
+
*/
|
|
122
202
|
function byRanking(cands, ranking) {
|
|
203
|
+
/** @param {Consultant} c */
|
|
123
204
|
const index = (c) => {
|
|
124
205
|
const i = ranking.findIndex((r) => normalizeName(r) === normalizeName(c.name) || matchesWords(c.model, r));
|
|
125
206
|
return i === -1 ? Infinity : i;
|
|
@@ -128,11 +209,18 @@ function byRanking(cands, ranking) {
|
|
|
128
209
|
return best === Infinity ? cands : cands.filter((c) => index(c) === best);
|
|
129
210
|
}
|
|
130
211
|
|
|
212
|
+
/**
|
|
213
|
+
* @param {readonly Consultant[]} cands
|
|
214
|
+
* @param {Tier} tier
|
|
215
|
+
*/
|
|
131
216
|
function byTier(cands, tier) {
|
|
132
217
|
if (!tier.family(cands)) return cands;
|
|
218
|
+
/** @param {Consultant} c */
|
|
133
219
|
const rank = (c) => {
|
|
134
220
|
const words = modelWords(c.model);
|
|
135
|
-
|
|
221
|
+
const last = words.at(-1);
|
|
222
|
+
if (tier.last) return last === undefined ? -1 : tier.words.indexOf(last);
|
|
223
|
+
return tier.words.findIndex((w) => words.includes(w));
|
|
136
224
|
};
|
|
137
225
|
const ranks = cands.map(rank);
|
|
138
226
|
if (ranks.includes(-1) || new Set(ranks).size === 1) return cands;
|
|
@@ -140,18 +228,29 @@ function byTier(cands, tier) {
|
|
|
140
228
|
return cands.filter((_, i) => ranks[i] === best);
|
|
141
229
|
}
|
|
142
230
|
|
|
231
|
+
/** @param {readonly Consultant[]} cands */
|
|
143
232
|
function byVersion(cands) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
233
|
+
const [first] = cands;
|
|
234
|
+
if (!first) return cands;
|
|
235
|
+
const stem = modelStem(first.model).join('-');
|
|
236
|
+
if (cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
|
|
237
|
+
const versioned = [];
|
|
238
|
+
for (const c of cands) {
|
|
239
|
+
const version = modelVersion(c.model);
|
|
240
|
+
if (version === null) return cands;
|
|
241
|
+
versioned.push({ c, version });
|
|
242
|
+
}
|
|
243
|
+
const best = versioned.map((x) => x.version).reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
|
|
244
|
+
return versioned.filter((x) => compareVersions(x.version, best) === 0).map((x) => x.c);
|
|
149
245
|
}
|
|
150
246
|
|
|
151
247
|
/**
|
|
152
248
|
* One of several candidates, by the operator's ranking, then the built-in
|
|
153
249
|
* tiers, then the version within one stem -- or null when none of those
|
|
154
250
|
* separates them. Never a guess: two names nothing orders stay two.
|
|
251
|
+
* @param {readonly Consultant[]} cands
|
|
252
|
+
* @param {readonly string[]} [ranking]
|
|
253
|
+
* @returns {Consultant | null}
|
|
155
254
|
*/
|
|
156
255
|
export function rankCandidates(cands, ranking = []) {
|
|
157
256
|
let left = byRanking(cands, ranking);
|
|
@@ -160,13 +259,19 @@ export function rankCandidates(cands, ranking = []) {
|
|
|
160
259
|
left = byTier(left, tier);
|
|
161
260
|
}
|
|
162
261
|
if (left.length > 1) left = byVersion(left);
|
|
163
|
-
return left.length === 1 ? left[0] : null;
|
|
262
|
+
return left.length === 1 ? (left[0] ?? null) : null;
|
|
164
263
|
}
|
|
165
264
|
|
|
265
|
+
/** @typedef {{ none: true, consultant?: undefined, ambiguous?: undefined } | { none?: undefined, consultant: Consultant, ambiguous?: undefined } | { none?: undefined, consultant?: undefined, ambiguous: readonly Consultant[] }} Resolution */
|
|
266
|
+
|
|
166
267
|
/**
|
|
167
268
|
* What a requested name means: a consultant's own name, then a CLI name, then
|
|
168
269
|
* a vendor word, then the words of a model id. Several candidates are ranked;
|
|
169
270
|
* ones that cannot be ranked come back as `ambiguous`, never as a pick.
|
|
271
|
+
* @param {unknown} input
|
|
272
|
+
* @param {readonly Consultant[]} consultants
|
|
273
|
+
* @param {readonly string[]} [ranking]
|
|
274
|
+
* @returns {Resolution}
|
|
170
275
|
*/
|
|
171
276
|
export function resolveConsultant(input, consultants, ranking = []) {
|
|
172
277
|
const key = normalizeName(input);
|
|
@@ -177,8 +282,9 @@ export function resolveConsultant(input, consultants, ranking = []) {
|
|
|
177
282
|
if (CLI_ALIASES[key]) cands = consultants.filter((c) => c.cli === CLI_ALIASES[key]);
|
|
178
283
|
else if (VENDOR_WORDS[key]) cands = consultants.filter((c) => c.vendor === VENDOR_WORDS[key]);
|
|
179
284
|
else cands = consultants.filter((c) => matchesWords(c.model, input));
|
|
180
|
-
|
|
181
|
-
if (
|
|
285
|
+
const [only] = cands;
|
|
286
|
+
if (!only) return { none: true };
|
|
287
|
+
if (cands.length === 1) return { consultant: only };
|
|
182
288
|
const winner = rankCandidates(cands, ranking);
|
|
183
289
|
return winner ? { consultant: winner } : { ambiguous: cands };
|
|
184
290
|
}
|
|
@@ -188,13 +294,23 @@ export function resolveConsultant(input, consultants, ranking = []) {
|
|
|
188
294
|
* CLI is checked once, through its first available consultant -- and a CLI
|
|
189
295
|
* name asks for exactly that one, never an ambiguity between its consultants.
|
|
190
296
|
* Any other name resolves like a request. null when nothing answers to it.
|
|
297
|
+
* @param {unknown} arg
|
|
298
|
+
* @param {readonly Consultant[]} available
|
|
299
|
+
* @param {readonly Consultant[]} all
|
|
300
|
+
* @param {readonly string[]} [ranking]
|
|
301
|
+
* @returns {Consultant[] | null}
|
|
191
302
|
*/
|
|
192
303
|
export function checkTargets(arg, available, all, ranking = []) {
|
|
304
|
+
/** @type {Map<string, Consultant>} */
|
|
193
305
|
const first = new Map();
|
|
194
306
|
for (const c of available) if (!first.has(c.cli)) first.set(c.cli, c);
|
|
195
307
|
if (arg === null || arg === undefined) return [...first.values()];
|
|
196
|
-
const
|
|
197
|
-
|
|
308
|
+
const key = normalizeName(arg);
|
|
309
|
+
const cli = typeof key === 'string' ? CLI_ALIASES[key] : undefined;
|
|
310
|
+
if (cli) {
|
|
311
|
+
const hit = first.get(cli);
|
|
312
|
+
return hit ? [hit] : null;
|
|
313
|
+
}
|
|
198
314
|
const r = resolveConsultant(arg, all, ranking);
|
|
199
315
|
return r.consultant ? [r.consultant] : null;
|
|
200
316
|
}
|
|
@@ -204,6 +320,9 @@ export function checkTargets(arg, available, all, ranking = []) {
|
|
|
204
320
|
* the words agree exactly; one being a prefix-like part of the other
|
|
205
321
|
* ("claude-opus-5" in "claude-opus-5-5") is "unknown", because saying either
|
|
206
322
|
* "same" or "different" there would be a claim the server cannot back.
|
|
323
|
+
* @param {unknown} callerModel
|
|
324
|
+
* @param {unknown} consultantModel
|
|
325
|
+
* @returns {'same' | 'unknown' | 'different'}
|
|
207
326
|
*/
|
|
208
327
|
export function sameModel(callerModel, consultantModel) {
|
|
209
328
|
const a = modelWords(callerModel);
|
|
@@ -215,8 +334,17 @@ export function sameModel(callerModel, consultantModel) {
|
|
|
215
334
|
|
|
216
335
|
const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
|
|
217
336
|
const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
|
|
218
|
-
const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'vendor', 'enabled', 'note'];
|
|
219
|
-
|
|
337
|
+
const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'fallback_model', 'vendor', 'enabled', 'note'];
|
|
338
|
+
/**
|
|
339
|
+
* @param {unknown} v
|
|
340
|
+
* @returns {v is Record<string, unknown>}
|
|
341
|
+
*/
|
|
342
|
+
export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
343
|
+
/**
|
|
344
|
+
* @param {number} v
|
|
345
|
+
* @param {number} min
|
|
346
|
+
* @param {number} max
|
|
347
|
+
*/
|
|
220
348
|
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
221
349
|
|
|
222
350
|
/**
|
|
@@ -224,6 +352,8 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
|
224
352
|
* as a problem string. A config without `consultants` gets one consultant per
|
|
225
353
|
* CLI, named after it, on that CLI's default model; whether it can run is the
|
|
226
354
|
* CLI's availability, decided by policy.mjs.
|
|
355
|
+
* @param {unknown} config the parsed config file, before validation
|
|
356
|
+
* @returns {BuiltConsultants}
|
|
227
357
|
*/
|
|
228
358
|
export function buildConsultants(config) {
|
|
229
359
|
// `problems` is every mistake, for the operator to read; `global` the ones
|
|
@@ -231,10 +361,15 @@ export function buildConsultants(config) {
|
|
|
231
361
|
// or one CLI is kept to it: that consultant cannot be used (`broken`), that
|
|
232
362
|
// CLI is off (`brokenClis`) -- the closed side either way, so nothing the
|
|
233
363
|
// operator turned off comes back on.
|
|
364
|
+
/** @type {string[]} */
|
|
234
365
|
const problems = [];
|
|
366
|
+
/** @type {string[]} */
|
|
235
367
|
const global = [];
|
|
368
|
+
/** @type {Record<string, string[]>} */
|
|
236
369
|
const broken = {};
|
|
370
|
+
/** @type {Record<string, string[]>} */
|
|
237
371
|
const brokenClis = {};
|
|
372
|
+
/** @param {string} msg */
|
|
238
373
|
const fault = (msg) => {
|
|
239
374
|
problems.push(msg);
|
|
240
375
|
global.push(msg);
|
|
@@ -247,7 +382,9 @@ export function buildConsultants(config) {
|
|
|
247
382
|
|
|
248
383
|
// The one server key that is not a number to clamp: a wrong value cannot
|
|
249
384
|
// be read as either setting, and reading it as "on" would open the web.
|
|
250
|
-
|
|
385
|
+
/** @type {readonly unknown[]} */
|
|
386
|
+
const networkModes = ['on', 'off'];
|
|
387
|
+
if (isObject(cfg.server) && cfg.server.network !== undefined && !networkModes.includes(cfg.server.network)) {
|
|
251
388
|
fault('server.network must be "on" or "off"');
|
|
252
389
|
}
|
|
253
390
|
|
|
@@ -258,11 +395,13 @@ export function buildConsultants(config) {
|
|
|
258
395
|
else fault('ranking must be an array of non-empty strings');
|
|
259
396
|
}
|
|
260
397
|
|
|
398
|
+
/** @type {readonly unknown[]} */
|
|
399
|
+
const cliIds = CLI_IDS;
|
|
261
400
|
if (cfg.clis !== undefined) {
|
|
262
401
|
if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
|
|
263
402
|
else {
|
|
264
403
|
for (const [id, def] of Object.entries(cfg.clis)) {
|
|
265
|
-
if (!
|
|
404
|
+
if (!cliIds.includes(id)) {
|
|
266
405
|
problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
|
|
267
406
|
continue;
|
|
268
407
|
}
|
|
@@ -294,6 +433,7 @@ export function buildConsultants(config) {
|
|
|
294
433
|
}
|
|
295
434
|
}
|
|
296
435
|
|
|
436
|
+
/** @type {Consultant[]} */
|
|
297
437
|
const consultants = [];
|
|
298
438
|
if (cfg.consultants === undefined) {
|
|
299
439
|
for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
|
|
@@ -319,9 +459,9 @@ export function buildConsultants(config) {
|
|
|
319
459
|
}
|
|
320
460
|
for (const k of Object.keys(def))
|
|
321
461
|
if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
|
|
322
|
-
if (!
|
|
462
|
+
if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
|
|
323
463
|
if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
|
|
324
|
-
for (const k of ['effort', 'vendor', 'note']) {
|
|
464
|
+
for (const k of ['effort', 'fallback_model', 'vendor', 'note']) {
|
|
325
465
|
if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
|
|
326
466
|
}
|
|
327
467
|
if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
|
|
@@ -329,6 +469,10 @@ export function buildConsultants(config) {
|
|
|
329
469
|
if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
|
|
330
470
|
problems.push(`${at}.${k}: must be a number`);
|
|
331
471
|
}
|
|
472
|
+
if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === '')
|
|
473
|
+
problems.push(`${at}.fallback_model: must not be empty`);
|
|
474
|
+
else if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === String(def.model).trim())
|
|
475
|
+
problems.push(`${at}.fallback_model: must differ from model; the fallback is for when the model's quota is used up`);
|
|
332
476
|
if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
|
|
333
477
|
problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
|
|
334
478
|
// agy takes the effort from the model name and rejects --effort.
|
|
@@ -338,19 +482,40 @@ export function buildConsultants(config) {
|
|
|
338
482
|
broken[name] = problems.slice(before);
|
|
339
483
|
continue;
|
|
340
484
|
}
|
|
341
|
-
|
|
485
|
+
// The checks above have all passed: cli is a known id and model a non-empty string.
|
|
486
|
+
const valid = /** @type {ConsultantDef} */ (def);
|
|
487
|
+
const pair = `${valid.cli}\n${valid.model.trim()}`;
|
|
342
488
|
if (seen.has(pair)) {
|
|
343
489
|
problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
|
|
344
490
|
broken[name] = problems.slice(before);
|
|
345
491
|
continue;
|
|
346
492
|
}
|
|
347
493
|
seen.set(pair, name);
|
|
348
|
-
consultants.push(make(name,
|
|
494
|
+
consultants.push(make(name, valid));
|
|
349
495
|
}
|
|
350
496
|
}
|
|
351
497
|
return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
|
|
352
498
|
}
|
|
353
499
|
|
|
500
|
+
/**
|
|
501
|
+
* A consultant's definition in the config file, once it has passed validation.
|
|
502
|
+
* @typedef {object} ConsultantDef
|
|
503
|
+
* @property {CliId} cli
|
|
504
|
+
* @property {string} model
|
|
505
|
+
* @property {unknown} [effort]
|
|
506
|
+
* @property {unknown} [timeout_ms]
|
|
507
|
+
* @property {unknown} [max_budget_usd]
|
|
508
|
+
* @property {unknown} [fallback_model]
|
|
509
|
+
* @property {unknown} [vendor]
|
|
510
|
+
* @property {unknown} [enabled]
|
|
511
|
+
* @property {unknown} [note]
|
|
512
|
+
*/
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @param {string} name
|
|
516
|
+
* @param {ConsultantDef} def a definition that has passed validation
|
|
517
|
+
* @returns {Consultant}
|
|
518
|
+
*/
|
|
354
519
|
function make(name, def) {
|
|
355
520
|
const model = def.model.trim();
|
|
356
521
|
return Object.freeze({
|
|
@@ -360,7 +525,8 @@ function make(name, def) {
|
|
|
360
525
|
effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
|
|
361
526
|
timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
|
|
362
527
|
maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
|
|
363
|
-
|
|
528
|
+
fallbackModel: typeof def.fallback_model === 'string' && def.fallback_model.trim() !== '' ? def.fallback_model.trim() : null,
|
|
529
|
+
vendor: vendorOf(model, def.vendor),
|
|
364
530
|
enabled: def.enabled !== false,
|
|
365
531
|
note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
|
|
366
532
|
});
|
|
@@ -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) {
|