severally-mcp 2.0.0 → 2.3.1
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/CODE_OF_CONDUCT.md +83 -0
- package/CONFIG.md +13 -7
- package/README.ja.md +157 -77
- package/README.md +162 -81
- package/bin/severally-mcp.mjs +3 -0
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +11 -3
- package/docs/how-it-works.md +12 -4
- package/docs/install.ja.md +25 -6
- package/docs/install.md +30 -7
- package/package.json +5 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +4 -2
- package/plugins/severally/.mcp.json +2 -1
- package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
- package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
- package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
- package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
- package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
- package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
- package/plugins/severally/skills/claude/severally/reference.md +22 -10
- package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
- package/plugins/severally/skills/codex/severally/reference.md +22 -10
- package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
- package/plugins/severally/skills/opencode/severally/reference.md +22 -10
- package/scripts/init-config.mjs +1 -1
- package/scripts/install.mjs +124 -9
- package/src/adapters/antigravity-sandbox.mjs +5 -1
- package/src/adapters/codex.mjs +17 -6
- package/src/brief.mjs +38 -16
- package/src/consultants.mjs +31 -10
- package/src/expose-paths.mjs +68 -5
- package/src/jobs.mjs +294 -56
- package/src/policy.mjs +49 -11
- package/src/redact.mjs +20 -1
- package/src/run.mjs +38 -5
- package/src/schema.mjs +35 -12
- package/src/server.mjs +96 -120
- package/src/status.mjs +63 -0
- package/src/store.mjs +44 -1
package/src/policy.mjs
CHANGED
|
@@ -35,21 +35,50 @@ const CONFIG = (() => {
|
|
|
35
35
|
}
|
|
36
36
|
})();
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// read why, but every consultation is
|
|
41
|
-
// quietly re-enable a CLI the
|
|
38
|
+
// A mistake that leaves the whole file untrustworthy (unreadable, an unknown
|
|
39
|
+
// top-level key, a section of the wrong shape) is not run on defaults: the
|
|
40
|
+
// server still starts, so the operator can read why, but every consultation is
|
|
41
|
+
// refused (jobs.mjs) -- falling back would quietly re-enable a CLI the
|
|
42
|
+
// operator turned off. A mistake inside one consultant or one CLI only takes
|
|
43
|
+
// that one out of use, with the reason.
|
|
42
44
|
const BUILT = buildConsultants(CONFIG);
|
|
43
|
-
if (!configError && BUILT.
|
|
45
|
+
if (!configError && BUILT.global.length) configError = `${CONFIG_PATH}: ${BUILT.global.join('; ')}`;
|
|
46
|
+
// A value that is neither, from the environment, cannot be read as either: reading it as "on" would open the web.
|
|
47
|
+
if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY_NETWORK)) {
|
|
48
|
+
configError = `SEVERALLY_NETWORK must be "on" or "off", not ${JSON.stringify(process.env.SEVERALLY_NETWORK)}`;
|
|
49
|
+
}
|
|
44
50
|
const FROM = configError ? buildConsultants({}) : BUILT;
|
|
51
|
+
const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
|
|
45
52
|
|
|
46
|
-
/** What is wrong with the config file, or null. Reported at startup; while set, nothing can be started. */
|
|
53
|
+
/** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
|
|
47
54
|
export function configProblem() {
|
|
48
55
|
return configError;
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** The mistakes kept to one consultant or one CLI, each of which is out of use until fixed. */
|
|
59
|
+
export function configWarnings() {
|
|
60
|
+
return LOCAL.map((p) => `${CONFIG_PATH}: ${p}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The names of the consultants out of use because of a mistake in their own config section. */
|
|
64
|
+
export function brokenConsultantNames() {
|
|
65
|
+
return configError ? [] : Object.keys(BUILT.broken);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Why the consultant of this name is out of use because of a config mistake, or null. */
|
|
69
|
+
export function brokenConsultant(name) {
|
|
70
|
+
const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
|
|
71
|
+
return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
const usable = configError ? {} : CONFIG;
|
|
52
|
-
|
|
75
|
+
// A CLI whose own section has a mistake is off, whatever else the section says.
|
|
76
|
+
const cfgCli = (id) => {
|
|
77
|
+
if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
|
|
78
|
+
return { enabled: false, note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}` };
|
|
79
|
+
}
|
|
80
|
+
return usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {};
|
|
81
|
+
};
|
|
53
82
|
const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
|
|
54
83
|
|
|
55
84
|
/**
|
|
@@ -214,9 +243,14 @@ export const POLICY = Object.freeze({
|
|
|
214
243
|
// going beyond that is an operator decision with quota to match.
|
|
215
244
|
maxConcurrent: limit('SEVERALLY_MAX_CONCURRENT', cfgServer.max_concurrent, 9, 1, 9),
|
|
216
245
|
maxJobsRetained: limit('SEVERALLY_MAX_JOBS_RETAINED', cfgServer.max_jobs_retained, 200, 20, 2000),
|
|
217
|
-
// Upper bound for consult_get(wait_ms).
|
|
218
|
-
//
|
|
219
|
-
//
|
|
246
|
+
// Upper bound for consult_get(wait_ms). 45 s stays under the 60 s some MCP
|
|
247
|
+
// clients allow a tool call, so a long wait does not blow up as a client-side
|
|
248
|
+
// timeout while the consultation is still healthy; a client registration
|
|
249
|
+
// that is known to wait longer raises it (scripts/install.mjs).
|
|
250
|
+
// "off" takes the web away from every consultation, whatever the request
|
|
251
|
+
// asks; "on" leaves it to each request's own network.
|
|
252
|
+
// Either source can close it; neither can reopen what the other closed.
|
|
253
|
+
network: [process.env.SEVERALLY_NETWORK, cfgServer.network].includes('off') ? 'off' : 'on',
|
|
220
254
|
maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
|
|
221
255
|
|
|
222
256
|
input: Object.freeze({
|
|
@@ -261,7 +295,7 @@ export const POLICY = Object.freeze({
|
|
|
261
295
|
* different request, not a slower one.
|
|
262
296
|
*/
|
|
263
297
|
export function timeoutMs(consultant) {
|
|
264
|
-
return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms,
|
|
298
|
+
return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
|
|
265
299
|
}
|
|
266
300
|
|
|
267
301
|
/**
|
|
@@ -388,11 +422,15 @@ export function limitsSummary() {
|
|
|
388
422
|
const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
|
|
389
423
|
return {
|
|
390
424
|
available_targets: list.map((c) => c.name),
|
|
425
|
+
// A consultant or CLI out of use because of a mistake in its own section.
|
|
426
|
+
...(LOCAL.length ? { config_mistakes: configWarnings() } : {}),
|
|
391
427
|
clis: by((c) => c.cli),
|
|
392
428
|
models: by((c) => c.model),
|
|
393
429
|
// Per consultant, because they no longer share one budget.
|
|
394
430
|
timeout_ms: by(timeoutMs),
|
|
395
431
|
max_rounds_per_chain: POLICY.maxRounds,
|
|
432
|
+
// "off": the operator has taken the web away from every consultation.
|
|
433
|
+
network: POLICY.network,
|
|
396
434
|
max_concurrent_jobs: POLICY.maxConcurrent,
|
|
397
435
|
max_wait_ms: POLICY.maxWaitMs,
|
|
398
436
|
input_char_budget: POLICY.input.totalCharsMax,
|
package/src/redact.mjs
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
// Credential scrubbing. Applied on the way out (brief sent to the consultant),
|
|
2
2
|
// on the way in (consultant output), and to everything persisted to disk.
|
|
3
3
|
|
|
4
|
+
const CODE_WORDS = new Set(['true', 'false', 'null', 'none', 'undefined', 'string', 'str', 'number', 'boolean', 'bool', 'int', 'bytes']);
|
|
5
|
+
|
|
6
|
+
// A password-type value that is code, not a secret: a flag or a type, a
|
|
7
|
+
// variable reference (${X}, $X, process.env.X, os.environ[...]), and -- unless
|
|
8
|
+
// it is a long value under a full PASSWORD/PASSWD name, which stays masked
|
|
9
|
+
// whatever it looks like -- a dotted name, a call or a borrow (&str).
|
|
10
|
+
function looksLikeCode(name, value) {
|
|
11
|
+
if (CODE_WORDS.has(value.toLowerCase()) || value.startsWith('${') || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
|
|
12
|
+
|| /^(?:process\.env\.|os\.environ\b)/.test(value)) return true;
|
|
13
|
+
if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1))) return false;
|
|
14
|
+
return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith('&');
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
const PATTERNS = [
|
|
5
18
|
// Provider API keys
|
|
6
19
|
[/\bsk-ant-[A-Za-z0-9_\-]{12,}/g, '[REDACTED:anthropic-key]'],
|
|
@@ -9,6 +22,7 @@ const PATTERNS = [
|
|
|
9
22
|
[/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[REDACTED:github-token]'],
|
|
10
23
|
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
|
|
11
24
|
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
|
|
25
|
+
[/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/g, '[REDACTED:slack-webhook]'],
|
|
12
26
|
[/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
|
|
13
27
|
[/\bASIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
|
|
14
28
|
[/\bAIza[0-9A-Za-z_\-]{30,}/g, '[REDACTED:google-api-key]'],
|
|
@@ -25,8 +39,13 @@ const PATTERNS = [
|
|
|
25
39
|
// The password in a URL's userinfo, also with an empty user (redis://:pass@host)
|
|
26
40
|
[/(:\/\/[^\s:/@]*:)[^\s@/]+(@)/g, '$1[REDACTED]$2'],
|
|
27
41
|
// KEY=value / "token": "value" style assignments
|
|
28
|
-
[/\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|
|
|
42
|
+
[/\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|ACCOUNTKEY|SHAREDACCESSKEY|SECRET|TOKEN))(["']?\s*[:=]\s*["']?)([^\s"',;)]{8,})/gi,
|
|
29
43
|
(_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
|
|
44
|
+
// Password-type names, with values from 4 characters; PASS and PWD too (PWD
|
|
45
|
+
// only after `_` or a letter: bare PWD is the working directory). What reads
|
|
46
|
+
// as code rather than a password is left alone (looksLikeCode).
|
|
47
|
+
[/\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
|
|
48
|
+
(m, k, sep, v) => (looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`)],
|
|
30
49
|
// Any environment variable named *_KEY. Upper case only: in code, primary_key,
|
|
31
50
|
// sort_key and idempotency_key name things, not secrets.
|
|
32
51
|
[/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
|
package/src/run.mjs
CHANGED
|
@@ -164,15 +164,21 @@ export class ProcHandle {
|
|
|
164
164
|
this.stdoutSoFar = readStdout;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Kill the child and every descendant it spawned -- also after the child
|
|
169
|
+
* itself has exited: a descendant still holding its pipes keeps the
|
|
170
|
+
* consultation from closing, and on POSIX it is still in the group.
|
|
171
|
+
*/
|
|
168
172
|
stop(signal = 'SIGTERM') {
|
|
169
|
-
if (this.killed || !this.child
|
|
173
|
+
if (this.killed || !this.child) return;
|
|
170
174
|
this.killed = true;
|
|
171
175
|
const pid = this.child.pid;
|
|
172
176
|
if (!pid) return;
|
|
173
177
|
if (process.platform === 'win32') {
|
|
174
178
|
// Negative PIDs/process groups are POSIX-only. Kill the Windows tree
|
|
175
|
-
// before its parent exits and its descendants become unreachable.
|
|
179
|
+
// before its parent exits and its descendants become unreachable. Once
|
|
180
|
+
// it has exited, its pid may already be another process's: leave it.
|
|
181
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) return;
|
|
176
182
|
const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
|
|
177
183
|
['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
178
184
|
const fallback = () => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } };
|
|
@@ -201,6 +207,12 @@ export class ProcHandle {
|
|
|
201
207
|
/**
|
|
202
208
|
* @returns {{handle: ProcHandle, done: Promise<{code, signal, stdout, stderr, timedOut, cancelled, truncated}>}}
|
|
203
209
|
*/
|
|
210
|
+
/**
|
|
211
|
+
* `pipesForced` in the result: the child exited but something it started kept
|
|
212
|
+
* stdout or stderr open past the kill grace, so the group was stopped and the
|
|
213
|
+
* pipes closed from this side. A descendant outside the group (setsid, or on
|
|
214
|
+
* Windows once the parent is gone) may still be running.
|
|
215
|
+
*/
|
|
204
216
|
export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSignal, compactLine = null,
|
|
205
217
|
settleAfter = null, stopSignal = 'SIGTERM' }) {
|
|
206
218
|
const child = spawn(command, args, {
|
|
@@ -288,16 +300,37 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
288
300
|
|
|
289
301
|
const done = new Promise((resolve) => {
|
|
290
302
|
let settled = false;
|
|
303
|
+
let pipesForced = false;
|
|
304
|
+
let orphanTimer = null;
|
|
291
305
|
const finish = (payload) => {
|
|
292
306
|
if (settled) return;
|
|
293
307
|
settled = true;
|
|
294
308
|
clearTimeout(timer);
|
|
295
309
|
clearTimeout(settleTimer);
|
|
296
|
-
|
|
310
|
+
clearTimeout(orphanTimer);
|
|
311
|
+
resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
|
|
297
312
|
};
|
|
313
|
+
const flushPending = () => { if (byLine && pending) { append('out', compact(pending)); pending = ''; } };
|
|
298
314
|
child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
|
|
315
|
+
// 'close' waits for the pipes as well as the child. Once the child has
|
|
316
|
+
// exited, a grace later the rest of its group is stopped, and a grace
|
|
317
|
+
// after that the pipes are closed from here, whoever still holds them.
|
|
318
|
+
child.on('exit', (code, signal) => {
|
|
319
|
+
const grace = POLICY.killGraceMs;
|
|
320
|
+
orphanTimer = setTimeout(() => {
|
|
321
|
+
pipesForced = true;
|
|
322
|
+
handle.stop(stopSignal);
|
|
323
|
+
orphanTimer = setTimeout(() => {
|
|
324
|
+
handle.forceKill();
|
|
325
|
+
flushPending();
|
|
326
|
+
child.stdout.destroy();
|
|
327
|
+
child.stderr.destroy();
|
|
328
|
+
finish({ code, signal });
|
|
329
|
+
}, grace);
|
|
330
|
+
}, grace);
|
|
331
|
+
});
|
|
299
332
|
child.on('close', (code, signal) => {
|
|
300
|
-
|
|
333
|
+
flushPending();
|
|
301
334
|
finish({ code, signal });
|
|
302
335
|
});
|
|
303
336
|
});
|
package/src/schema.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
POLICY, CALLER_INPUTS, MODES, STANCES, artifactKinds,
|
|
9
|
-
resolveCaller, availableConsultants, consultantAvailable, unavailableReason,
|
|
9
|
+
resolveCaller, availableConsultants, consultantAvailable, unavailableReason, brokenConsultant,
|
|
10
10
|
} from './policy.mjs';
|
|
11
11
|
import { normalizeName, resolveConsultant } from './consultants.mjs';
|
|
12
12
|
|
|
@@ -42,10 +42,10 @@ const gitRevision = z.string().trim().min(1).max(200)
|
|
|
42
42
|
|
|
43
43
|
export const contextSchema = z
|
|
44
44
|
.object({
|
|
45
|
-
facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).
|
|
45
|
+
facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).optional(),
|
|
46
46
|
proposal: z.string().trim().max(L.proposalMax).nullish(),
|
|
47
|
-
counterpoints: z.array(trimmed(L.counterpointMax, 'context.counterpoints[]')).max(L.counterpointsMax).
|
|
48
|
-
artifacts: z.array(artifactSchema).max(L.artifactsMax).
|
|
47
|
+
counterpoints: z.array(trimmed(L.counterpointMax, 'context.counterpoints[]')).max(L.counterpointsMax).optional(),
|
|
48
|
+
artifacts: z.array(artifactSchema).max(L.artifactsMax).optional(),
|
|
49
49
|
expose_paths: z.array(exposedPathSchema).max(L.exposePathsMax).default([]),
|
|
50
50
|
// The lead's uncommitted (or since-`base`) change: its diff goes in the
|
|
51
51
|
// brief and the files it touches are copied, under workspace/repo/ at
|
|
@@ -55,6 +55,9 @@ export const contextSchema = z
|
|
|
55
55
|
// round exposed. Never implied -- what reaches another vendor's model is
|
|
56
56
|
// what the lead named in this call.
|
|
57
57
|
expose_from_parent: z.boolean().optional(),
|
|
58
|
+
// Read as the request's own: a lead often nests them beside the facts.
|
|
59
|
+
constraints: z.array(trimmed(L.constraintMax, 'context.constraints[]')).max(L.constraintsMax).optional(),
|
|
60
|
+
success_criteria: z.array(trimmed(L.constraintMax, 'context.success_criteria[]')).max(L.constraintsMax).optional(),
|
|
58
61
|
})
|
|
59
62
|
.strict();
|
|
60
63
|
|
|
@@ -100,13 +103,13 @@ export const requestSchema = z
|
|
|
100
103
|
// "off" takes web search and browsing away from every consultant in this
|
|
101
104
|
// round, for a brief that must not leave the machine through a URL. The
|
|
102
105
|
// consultant still reads the brief and the disk; it reaches nothing else.
|
|
103
|
-
network: z.enum(NETWORK_MODES).
|
|
106
|
+
network: z.enum(NETWORK_MODES).optional(),
|
|
104
107
|
question: trimmed(L.questionMax, 'question'),
|
|
105
108
|
// Optional: most consultations state what they are after in the question
|
|
106
109
|
// itself, and a second field that repeats it is friction, not information.
|
|
107
110
|
objective: trimmed(L.objectiveMax, 'objective').nullish(),
|
|
108
|
-
success_criteria: z.array(trimmed(L.constraintMax, 'success_criteria[]')).max(L.constraintsMax).
|
|
109
|
-
constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).
|
|
111
|
+
success_criteria: z.array(trimmed(L.constraintMax, 'success_criteria[]')).max(L.constraintsMax).optional(),
|
|
112
|
+
constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).optional(),
|
|
110
113
|
// prefault, not default: zod 4 returns a default as is, skipping the inner defaults (expose_paths among them).
|
|
111
114
|
context: contextSchema.prefault({}),
|
|
112
115
|
// Written by the lead before the consultant is launched, kept on this
|
|
@@ -147,15 +150,31 @@ export class RequestError extends Error {
|
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
152
|
* Validate + normalise a consult request.
|
|
150
|
-
* `isFollowup` is decided by the caller (the job store resolves followup_to first)
|
|
153
|
+
* `isFollowup` is decided by the caller (the job store resolves followup_to first);
|
|
154
|
+
* `groupFollowup` when followup_to named a fan-out, whose members are the targets.
|
|
151
155
|
*/
|
|
152
|
-
export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
|
|
156
|
+
export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
|
|
153
157
|
const parsed = requestSchema.safeParse(raw);
|
|
154
158
|
if (!parsed.success) {
|
|
155
159
|
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
|
156
160
|
throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
|
|
157
161
|
}
|
|
158
162
|
const req = parsed.data;
|
|
163
|
+
// Left without a default in the schema, so that a follow-up can tell a list
|
|
164
|
+
// it left out (inherited from the round before, jobs.mjs) from one it
|
|
165
|
+
// emptied on purpose: the tool layer applies the schema's defaults first.
|
|
166
|
+
for (const k of ['constraints', 'success_criteria']) {
|
|
167
|
+
if (req.context[k] === undefined) continue;
|
|
168
|
+
if (req[k] !== undefined) {
|
|
169
|
+
throw new RequestError(`pass ${k} once: at the top of the request or inside context, not both`, 'invalid_request');
|
|
170
|
+
}
|
|
171
|
+
req[k] = req.context[k];
|
|
172
|
+
delete req.context[k];
|
|
173
|
+
}
|
|
174
|
+
req.network ??= 'on';
|
|
175
|
+
req.success_criteria ??= [];
|
|
176
|
+
req.constraints ??= [];
|
|
177
|
+
for (const k of ['facts', 'counterpoints', 'artifacts']) req.context[k] ??= [];
|
|
159
178
|
const hasSingle = req.target !== undefined && req.target !== null;
|
|
160
179
|
const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
|
|
161
180
|
if (hasSingle && hasMany) {
|
|
@@ -191,6 +210,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
|
|
|
191
210
|
chosen = named.map((name) => {
|
|
192
211
|
let r = resolveConsultant(name, reachable, POLICY.ranking);
|
|
193
212
|
if (r.none) r = resolveConsultant(name, POLICY.consultants, POLICY.ranking);
|
|
213
|
+
if (r.none && brokenConsultant(name)) {
|
|
214
|
+
throw new RequestError(`consultant ${JSON.stringify(name)} is not available: ${brokenConsultant(name)}; available: ${listed()}`, 'target_unavailable');
|
|
215
|
+
}
|
|
194
216
|
if (r.none) {
|
|
195
217
|
throw new RequestError(`no consultant answers to ${JSON.stringify(name)}; available: ${listed()}`, 'unknown_target');
|
|
196
218
|
}
|
|
@@ -220,9 +242,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
|
|
|
220
242
|
'duplicate_targets',
|
|
221
243
|
);
|
|
222
244
|
}
|
|
223
|
-
if (isFollowup && unique.length > 1) {
|
|
245
|
+
if (isFollowup && !groupFollowup && unique.length > 1) {
|
|
224
246
|
throw new RequestError(
|
|
225
|
-
'a follow-up continues the exchange with one consultant; name exactly one target',
|
|
247
|
+
'a follow-up to a job continues the exchange with one consultant; name exactly one target, or pass followup_to a group_id to ask every member of that fan-out again',
|
|
226
248
|
'followup_fanout_not_allowed',
|
|
227
249
|
);
|
|
228
250
|
}
|
|
@@ -277,7 +299,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
|
|
|
277
299
|
if (req.context.facts.length === 0 && req.context.artifacts.length === 0 && req.context.expose_paths.length === 0
|
|
278
300
|
&& !req.context.expose_diff && !req.context.expose_from_parent) {
|
|
279
301
|
throw new RequestError(
|
|
280
|
-
'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request'
|
|
302
|
+
'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request'
|
|
303
|
+
+ (isFollowup ? '; on a follow-up, context.expose_from_parent: true copies again the files the earlier round was shown' : ''),
|
|
281
304
|
'context_required',
|
|
282
305
|
);
|
|
283
306
|
}
|