severally-mcp 2.0.0 → 2.1.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 +4 -2
- package/README.ja.md +28 -15
- package/README.md +29 -15
- package/docs/how-it-works.ja.md +7 -1
- package/docs/how-it-works.md +7 -1
- package/docs/install.ja.md +13 -1
- package/docs/install.md +16 -2
- package/package.json +2 -2
- 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 +327 -94
- package/plugins/severally/skills/_template/SKILL.md.tmpl +5 -3
- package/plugins/severally/skills/_template/reference.md.tmpl +12 -6
- package/plugins/severally/skills/antigravity/severally/SKILL.md +5 -3
- package/plugins/severally/skills/antigravity/severally/reference.md +12 -6
- package/plugins/severally/skills/claude/severally/SKILL.md +5 -3
- package/plugins/severally/skills/claude/severally/reference.md +12 -6
- package/plugins/severally/skills/codex/severally/SKILL.md +5 -3
- package/plugins/severally/skills/codex/severally/reference.md +12 -6
- package/plugins/severally/skills/opencode/severally/SKILL.md +5 -3
- package/plugins/severally/skills/opencode/severally/reference.md +12 -6
- package/scripts/init-config.mjs +1 -1
- package/scripts/install.mjs +35 -5
- package/src/adapters/antigravity-sandbox.mjs +5 -1
- package/src/adapters/codex.mjs +17 -6
- package/src/brief.mjs +38 -16
- package/src/expose-paths.mjs +68 -5
- package/src/jobs.mjs +171 -45
- package/src/policy.mjs +5 -4
- package/src/redact.mjs +20 -1
- package/src/schema.mjs +5 -4
- package/src/server.mjs +44 -16
package/src/expose-paths.mjs
CHANGED
|
@@ -105,6 +105,12 @@ function walkDir(root, dir, onFile, totals, skipped) {
|
|
|
105
105
|
}
|
|
106
106
|
for (const item of items) {
|
|
107
107
|
const child = path.join(dir, item.name);
|
|
108
|
+
// Outside git there is no .gitignore to say what is not source: these two
|
|
109
|
+
// are never what a consultant should read, and would hit the caps alone.
|
|
110
|
+
if (item.name === '.git' || item.name === 'node_modules') {
|
|
111
|
+
skipped.push({ source: child, reason: 'ignored' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
108
114
|
// Not followed: a link inside a directory the lead exposed can resolve
|
|
109
115
|
// anywhere on the disk, which is exactly the scope this feature exists
|
|
110
116
|
// to keep drawn. Recorded rather than dropped silently, so a consultant
|
|
@@ -130,6 +136,61 @@ function walkDir(root, dir, onFile, totals, skipped) {
|
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// walkDir's order: name by name at every level, not the whole path as one
|
|
140
|
+
// string ("a/x" comes before "a-b" there, after it as a string).
|
|
141
|
+
function byPathParts(a, b) {
|
|
142
|
+
const x = a.split('/');
|
|
143
|
+
const y = b.split('/');
|
|
144
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
145
|
+
return x.length - y.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A directory inside a git work tree: the candidates are what git lists
|
|
149
|
+
// (tracked, plus untracked that .gitignore does not exclude), so `.git` and
|
|
150
|
+
// ignored trees drop out. Every candidate gets walkDir's checks again -- a
|
|
151
|
+
// committed .env is still protected, a listed file may be gone, a link, a
|
|
152
|
+
// submodule, or reach outside the directory. False when this is not a work
|
|
153
|
+
// tree, git is missing or lists nothing (an ignored directory the lead named
|
|
154
|
+
// on purpose): the caller walks it instead.
|
|
155
|
+
function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
156
|
+
let listed;
|
|
157
|
+
let ignored;
|
|
158
|
+
try {
|
|
159
|
+
listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
|
|
160
|
+
ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
|
|
161
|
+
} catch { return false; }
|
|
162
|
+
if (listed.length === 0) return false;
|
|
163
|
+
for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
|
|
164
|
+
if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
|
|
165
|
+
const realDir = fs.realpathSync(dir);
|
|
166
|
+
const protectedDirs = new Set();
|
|
167
|
+
for (const rel of listed.sort(byPathParts)) {
|
|
168
|
+
const abs = path.join(dir, rel);
|
|
169
|
+
// walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
|
|
170
|
+
// listed file is checked with each directory above it for the same reason.
|
|
171
|
+
const parts = rel.split('/');
|
|
172
|
+
const guarded = parts.slice(0, -1).map((_, i) => path.join(dir, ...parts.slice(0, i + 1))).find(isProtectedPath);
|
|
173
|
+
if (guarded) {
|
|
174
|
+
if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
|
|
175
|
+
protectedDirs.add(guarded);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (isProtectedPath(abs)) { skipped.push({ source: abs, reason: 'protected' }); continue; }
|
|
179
|
+
let stat;
|
|
180
|
+
// Deleted in the work tree but still in the index: not part of the tree
|
|
181
|
+
// as it is now, so not part of the copy either.
|
|
182
|
+
try { stat = fs.lstatSync(abs); } catch { continue; }
|
|
183
|
+
// 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; }
|
|
187
|
+
if (!stat.isFile()) continue;
|
|
188
|
+
onFile(abs, path.normalize(rel));
|
|
189
|
+
checkBudget(totals);
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
133
194
|
// Each path is a string, or { source, index } to keep the name an earlier
|
|
134
195
|
// round gave it.
|
|
135
196
|
function walkEntries(exposePaths, onFile) {
|
|
@@ -138,6 +199,9 @@ function walkEntries(exposePaths, onFile) {
|
|
|
138
199
|
const entries = exposePaths.map((p, i) => {
|
|
139
200
|
const { source, index } = typeof p === 'string' ? { source: p, index: i } : p;
|
|
140
201
|
const stat = statEntry(source);
|
|
202
|
+
// Indexed, so two entries whose basenames collide (two different
|
|
203
|
+
// util.mjs) stay apart, and the brief can name each one exactly.
|
|
204
|
+
const exposedAs = `workspace/${index}-${path.basename(source)}`;
|
|
141
205
|
const before = { files: totals.files, bytes: totals.bytes };
|
|
142
206
|
// What a directory holds, for the brief: not every consultant has a tool
|
|
143
207
|
// that lists a directory, and a name it cannot see is a file it never reads.
|
|
@@ -148,18 +212,17 @@ function walkEntries(exposePaths, onFile) {
|
|
|
148
212
|
onFile(source, '', index, source, totals);
|
|
149
213
|
checkBudget(totals);
|
|
150
214
|
} else {
|
|
151
|
-
|
|
215
|
+
const add = (abs, rel) => {
|
|
152
216
|
paths.push(rel.split(path.sep).join('/'));
|
|
153
217
|
onFile(abs, rel, index, source, totals);
|
|
154
|
-
}
|
|
218
|
+
};
|
|
219
|
+
walkGit(source, exposedAs, add, totals, skipped) || walkDir(source, source, add, totals, skipped);
|
|
155
220
|
}
|
|
156
221
|
return {
|
|
157
222
|
index,
|
|
158
223
|
source,
|
|
159
224
|
kind: stat.isDirectory() ? 'directory' : 'file',
|
|
160
|
-
|
|
161
|
-
// util.mjs) stay apart, and the brief can name each one exactly.
|
|
162
|
-
exposedAs: `workspace/${index}-${path.basename(source)}`,
|
|
225
|
+
exposedAs,
|
|
163
226
|
files: totals.files - before.files,
|
|
164
227
|
bytes: totals.bytes - before.bytes,
|
|
165
228
|
paths: paths.sort(),
|
package/src/jobs.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName } from './policy.mjs';
|
|
7
|
+
import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName, consultantAvailable } from './policy.mjs';
|
|
8
8
|
import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
|
|
9
9
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
10
10
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
@@ -296,7 +296,19 @@ export class JobManager {
|
|
|
296
296
|
? rawRequest.followup_to.trim()
|
|
297
297
|
: null;
|
|
298
298
|
let parent = null;
|
|
299
|
-
|
|
299
|
+
let fromGroup = null;
|
|
300
|
+
if (followupTo?.startsWith('group_')) {
|
|
301
|
+
// A follow-up to a fan-out goes to every member that can take one; the
|
|
302
|
+
// members are the targets, so the request names none.
|
|
303
|
+
if ((rawRequest.target ?? null) !== null || rawRequest.targets?.length) {
|
|
304
|
+
throw new RequestError(
|
|
305
|
+
'a follow-up to a group goes to every member that can take one, so leave target and targets out; to follow up one member, pass its job_id',
|
|
306
|
+
'followup_group_targets',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
fromGroup = this.#groupParents(followupTo);
|
|
310
|
+
rawRequest = { ...rawRequest, targets: fromGroup.links.map((l) => l.parent.target) };
|
|
311
|
+
} else if (followupTo) {
|
|
300
312
|
// A job from an earlier server session is read back from the history,
|
|
301
313
|
// so a restart does not end a consultation the lead is still checking.
|
|
302
314
|
parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
|
|
@@ -322,8 +334,18 @@ export class JobManager {
|
|
|
322
334
|
parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
|
|
323
335
|
}
|
|
324
336
|
|
|
325
|
-
const req = parseRequest(rawRequest, {
|
|
337
|
+
const req = parseRequest(rawRequest, {
|
|
338
|
+
isFollowup: Boolean(parent || fromGroup),
|
|
339
|
+
parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
|
|
340
|
+
groupFollowup: Boolean(fromGroup),
|
|
341
|
+
});
|
|
326
342
|
|
|
343
|
+
if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
|
|
344
|
+
throw new RequestError(
|
|
345
|
+
`the members of group "${followupTo}" no longer resolve to the consultants that answered them; follow up each member by its job_id`,
|
|
346
|
+
'followup_target_mismatch',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
327
349
|
if (parent && parent.target !== req.target) {
|
|
328
350
|
throw new RequestError(
|
|
329
351
|
`follow-up must go to the same consultant as job "${parent.job_id}" (${parent.target})`,
|
|
@@ -331,7 +353,7 @@ export class JobManager {
|
|
|
331
353
|
);
|
|
332
354
|
}
|
|
333
355
|
const round = parent ? parent.nextRound : 1;
|
|
334
|
-
if (round > POLICY.maxRounds) {
|
|
356
|
+
if (!fromGroup && round > POLICY.maxRounds) {
|
|
335
357
|
throw new RequestError(
|
|
336
358
|
`this consultation has used its ${POLICY.maxRounds} rounds (1 initial + ${POLICY.maxRounds - 1} follow-ups); decide with what you have, or start a new consultation with a materially different question`,
|
|
337
359
|
'round_limit',
|
|
@@ -361,14 +383,57 @@ export class JobManager {
|
|
|
361
383
|
}
|
|
362
384
|
// A request refused from here on gives its seats back.
|
|
363
385
|
try {
|
|
364
|
-
|
|
386
|
+
// Each target's parent round and the round it opens, in req.targets order.
|
|
387
|
+
const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
|
|
388
|
+
return this.#admitted(req, links, seats, fromGroup);
|
|
365
389
|
} catch (err) {
|
|
366
390
|
for (const seat of seats) if (!this.jobs.has(seat.job_id)) store.releaseSeat(seat.job_id);
|
|
367
391
|
throw err;
|
|
368
392
|
}
|
|
369
393
|
}
|
|
370
394
|
|
|
371
|
-
|
|
395
|
+
// A follow-up to a fan-out: each member goes on from the newest answered
|
|
396
|
+
// round of its own chain. A member that is still running, never answered or
|
|
397
|
+
// has used its rounds is skipped and named; if that is every member, the
|
|
398
|
+
// request is refused.
|
|
399
|
+
#groupParents(groupId) {
|
|
400
|
+
const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
|
|
401
|
+
if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
|
|
402
|
+
const links = [];
|
|
403
|
+
const skipped = [];
|
|
404
|
+
for (const jid of group.job_ids) {
|
|
405
|
+
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
406
|
+
if (!member) {
|
|
407
|
+
skipped.push({ job_id: jid, target: null, reason: 'not found: aged out of the history, or still running in another severally process' });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const rounds = this.#chainRounds(member.chain_id);
|
|
411
|
+
const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
|
|
412
|
+
const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
|
|
413
|
+
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
414
|
+
// since is skipped, never continued by whichever one answers to it now.
|
|
415
|
+
const consultant = consultantByName(member.target);
|
|
416
|
+
const reason = !latest ? `${member.status}, so there is no answer to follow up`
|
|
417
|
+
: latest.status !== 'completed' ? `round ${latest.round} is still ${latest.status}`
|
|
418
|
+
: nextRound > POLICY.maxRounds ? `has used its ${POLICY.maxRounds} rounds`
|
|
419
|
+
: !consultant || !consultantAvailable(consultant) ? 'this consultant is no longer configured or available here'
|
|
420
|
+
: null;
|
|
421
|
+
if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
|
|
422
|
+
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
423
|
+
}
|
|
424
|
+
if (!links.length) {
|
|
425
|
+
throw new RequestError(
|
|
426
|
+
`no member of group "${groupId}" can take a follow-up: ${skipped.map((x) => `${x.target ?? x.job_id} (${x.reason})`).join(', ')}`,
|
|
427
|
+
'followup_nothing_to_continue',
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return { group, links, skipped };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#admitted(req, links, seats, fromGroup = null) {
|
|
434
|
+
// What expose_from_parent copies again: for a group, what that fan-out
|
|
435
|
+
// exposed (one manifest, shared by its members); otherwise the parent's.
|
|
436
|
+
const parent = links[0].groupMember ?? links[0].parent;
|
|
372
437
|
const groupId = id('group');
|
|
373
438
|
// Copied once for the whole request, before a round is counted or a job
|
|
374
439
|
// exists, into the group's own directory: every member is then handed the
|
|
@@ -428,7 +493,8 @@ export class JobManager {
|
|
|
428
493
|
req._snapshot = path.join(snapshot, 'workspace');
|
|
429
494
|
}
|
|
430
495
|
const members = req.targets.map((target, i) => {
|
|
431
|
-
const
|
|
496
|
+
const { parent: from, round } = links[i];
|
|
497
|
+
const chainId = from ? from.chain_id : id('chain');
|
|
432
498
|
const jobId = seats[i].job_id;
|
|
433
499
|
const job = {
|
|
434
500
|
job_id: jobId,
|
|
@@ -462,7 +528,7 @@ export class JobManager {
|
|
|
462
528
|
retries: [],
|
|
463
529
|
partial_output: null,
|
|
464
530
|
tool_calls: null,
|
|
465
|
-
followup_to:
|
|
531
|
+
followup_to: from?.job_id ?? null,
|
|
466
532
|
status: 'queued',
|
|
467
533
|
// What this consultant is: the CLI it runs on, the model and its
|
|
468
534
|
// lineage, as the operator defined it.
|
|
@@ -500,14 +566,13 @@ export class JobManager {
|
|
|
500
566
|
this.groups.set(groupId, group);
|
|
501
567
|
try { store.persistGroup(group); } catch { /* history is best effort */ }
|
|
502
568
|
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// follow-up
|
|
507
|
-
// empty, and a follow-up is always a group of one on the parent's chain.
|
|
508
|
-
const chain = { round, priorRounds: this.#chainHistory(members[0].chain_id) };
|
|
509
|
-
const brief = renderBrief(req, chain, exposeManifest);
|
|
569
|
+
// A fan-out is only comparable if every consultant answered the same
|
|
570
|
+
// question in the same words: every member's brief is the same request,
|
|
571
|
+
// rendered the same way. Only the earlier rounds differ -- each member's
|
|
572
|
+
// own, on a follow-up to a group; none, on a first round.
|
|
510
573
|
for (const job of members) {
|
|
574
|
+
const chain = { round: job.round, priorRounds: this.#chainHistory(job.chain_id) };
|
|
575
|
+
const brief = renderBrief(req, chain, exposeManifest);
|
|
511
576
|
job.promise = this.#execute(job, { ...req, target: job.target }, chain, brief).catch((err) => {
|
|
512
577
|
this.#fail(job, 'cli_error', err?.message ?? String(err));
|
|
513
578
|
});
|
|
@@ -517,11 +582,14 @@ export class JobManager {
|
|
|
517
582
|
// if the process dies mid-consultation. Keep that copy ahead of any await.
|
|
518
583
|
if (req._snapshot) store.cleanupJobDir(groupId);
|
|
519
584
|
|
|
585
|
+
// The members of a follow-up to a group can be at different rounds.
|
|
586
|
+
const { round } = members[0];
|
|
587
|
+
const sameRound = members.every((m) => m.round === round);
|
|
520
588
|
const common = {
|
|
521
589
|
group_id: groupId,
|
|
522
|
-
round,
|
|
590
|
+
...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
|
|
591
|
+
...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
|
|
523
592
|
...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
|
|
524
|
-
rounds_remaining: POLICY.maxRounds - round,
|
|
525
593
|
mode: req.mode,
|
|
526
594
|
// Counted, not refused: a brief of facts alone is a legitimate
|
|
527
595
|
// consultation, but one about code that hands over no file is one the
|
|
@@ -550,7 +618,10 @@ export class JobManager {
|
|
|
550
618
|
return {
|
|
551
619
|
...common,
|
|
552
620
|
question: group.question, // the redacted text every member was actually sent
|
|
553
|
-
jobs: members.map((m) => ({
|
|
621
|
+
jobs: members.map((m) => ({
|
|
622
|
+
job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model,
|
|
623
|
+
...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
|
|
624
|
+
})),
|
|
554
625
|
poll_with: `consult_get({ group_id: "${groupId}" })`,
|
|
555
626
|
note: 'Every consultant got the identical brief. When they come back, compare the grounds behind the points that differ; matching summaries are not evidence of agreement.',
|
|
556
627
|
};
|
|
@@ -558,9 +629,8 @@ export class JobManager {
|
|
|
558
629
|
|
|
559
630
|
// The rounds before a follow-up, from the history on disk and from memory
|
|
560
631
|
// (memory wins: it can hold a verdict written since the round was saved).
|
|
561
|
-
// Each
|
|
562
|
-
// knowing which of its points were checked and how they came out.
|
|
563
|
-
// finding that carries a verdict is passed; of the rest, the first five.
|
|
632
|
+
// Each point goes with the lead's verdict on it, so the consultant answers
|
|
633
|
+
// knowing which of its points were checked and how they came out.
|
|
564
634
|
#chainRounds(chainId) {
|
|
565
635
|
const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
|
|
566
636
|
for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
|
|
@@ -573,11 +643,24 @@ export class JobManager {
|
|
|
573
643
|
.sort((a, b) => a.round - b.round)
|
|
574
644
|
.map((j) => {
|
|
575
645
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
.
|
|
580
|
-
|
|
646
|
+
// The checked points first, then the unchecked ones, each with the
|
|
647
|
+
// lead's verdict and effect when there is one.
|
|
648
|
+
const ordered = (items, shape) => [
|
|
649
|
+
...items.filter((x) => verdicts.has(x.id)),
|
|
650
|
+
...items.filter((x) => !verdicts.has(x.id)),
|
|
651
|
+
].map((x) => ({ id: x.id, ...shape(x), verdict: verdicts.get(x.id)?.verdict ?? null, effect: verdicts.get(x.id)?.effect ?? null }));
|
|
652
|
+
const checked = (x) => x.verdict !== null;
|
|
653
|
+
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
654
|
+
return {
|
|
655
|
+
round: j.round,
|
|
656
|
+
mode: j.mode,
|
|
657
|
+
question: j.question,
|
|
658
|
+
summary: j.result.summary,
|
|
659
|
+
// Every checked finding; of the rest, the first five.
|
|
660
|
+
findings: [...findings.filter(checked), ...findings.filter((f) => !checked(f)).slice(0, 5)],
|
|
661
|
+
unknowns: ordered(j.result.unknowns, (u) => ({ point: u.item })).slice(0, 5),
|
|
662
|
+
next_checks: ordered(j.result.next_checks, (c) => ({ point: c.check })).slice(0, 5),
|
|
663
|
+
};
|
|
581
664
|
});
|
|
582
665
|
}
|
|
583
666
|
|
|
@@ -621,8 +704,9 @@ export class JobManager {
|
|
|
621
704
|
return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
|
|
622
705
|
}
|
|
623
706
|
}
|
|
624
|
-
// start() renders the brief
|
|
625
|
-
//
|
|
707
|
+
// start() renders the brief and hands it in: every member of a fan-out is
|
|
708
|
+
// sent the same request in the same words, with only its own earlier rounds
|
|
709
|
+
// added on a follow-up to the group.
|
|
626
710
|
const text = brief ?? renderBrief(req, chain);
|
|
627
711
|
const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
|
|
628
712
|
const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
|
|
@@ -766,6 +850,12 @@ export class JobManager {
|
|
|
766
850
|
}
|
|
767
851
|
const lastMessageText = invocation.lastMessagePath ? store.readIfExists(invocation.lastMessagePath) : '';
|
|
768
852
|
interpreted = adapter.interpret({ ...run, lastMessageText });
|
|
853
|
+
// An answer after an error the CLI reported is kept only if it parses;
|
|
854
|
+
// otherwise it is that error, re-run like any other.
|
|
855
|
+
const { recoveredError } = interpreted;
|
|
856
|
+
if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
|
|
857
|
+
interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
|
|
858
|
+
}
|
|
769
859
|
if (interpreted.ok) break;
|
|
770
860
|
// No envelope at all: what stderr holds is the dump from stopping it,
|
|
771
861
|
// not a message to classify. Not re-run, as it may have spent its quota.
|
|
@@ -834,7 +924,11 @@ export class JobManager {
|
|
|
834
924
|
}
|
|
835
925
|
|
|
836
926
|
job.result = normalized.result;
|
|
837
|
-
const notes = [
|
|
927
|
+
const notes = [
|
|
928
|
+
adviceCaveat(normalized),
|
|
929
|
+
sameVendorCaveat(job.caller, job.caller_model, consultant),
|
|
930
|
+
interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
931
|
+
].filter(Boolean);
|
|
838
932
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
839
933
|
// information -- and worse, it read as the server vouching for advice it
|
|
840
934
|
// cannot judge. What this server may assert is what it can derive from
|
|
@@ -1054,34 +1148,36 @@ export class JobManager {
|
|
|
1054
1148
|
message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
|
|
1055
1149
|
};
|
|
1056
1150
|
}
|
|
1151
|
+
// Counts only: the entries are what the lead just wrote, and
|
|
1152
|
+
// consult_get({ job_id, ids }) reads any of them back with its verdict.
|
|
1153
|
+
const summary = job.record && recordSummary(job);
|
|
1057
1154
|
return {
|
|
1058
1155
|
job_id: job.job_id,
|
|
1059
1156
|
chain_id: job.chain_id,
|
|
1060
1157
|
round: job.round,
|
|
1061
|
-
record:
|
|
1062
|
-
|
|
1063
|
-
reflection: job.reflection ?? null,
|
|
1158
|
+
record: summary ? { updated_at: summary.updated_at, verdicts: summary.verdicts, coverage: summary.coverage } : null,
|
|
1159
|
+
reflection_saved: job.reflection != null,
|
|
1064
1160
|
};
|
|
1065
1161
|
}
|
|
1066
1162
|
|
|
1067
|
-
async wait(jobId, waitMs, opts) {
|
|
1163
|
+
async wait(jobId, waitMs, opts, signal) {
|
|
1068
1164
|
const job = this.jobs.get(jobId);
|
|
1069
|
-
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs);
|
|
1165
|
+
if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
|
|
1070
1166
|
const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1071
1167
|
if (budget === 0 || job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
|
|
1072
1168
|
return this.view(jobId, opts);
|
|
1073
1169
|
}
|
|
1074
|
-
await
|
|
1170
|
+
await settled(job.promise, budget, signal);
|
|
1075
1171
|
return this.view(jobId, opts);
|
|
1076
1172
|
}
|
|
1077
1173
|
|
|
1078
1174
|
// A consultation another process runs has no promise here, so its history
|
|
1079
1175
|
// is re-read once a second until it is no longer running or the wait is up.
|
|
1080
|
-
async #waitOnDisk(read, waitMs) {
|
|
1176
|
+
async #waitOnDisk(read, waitMs, signal) {
|
|
1081
1177
|
const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1082
1178
|
let view = read();
|
|
1083
|
-
while (view && ['running', 'queued'].includes(view.status) && Date.now() < until) {
|
|
1084
|
-
await
|
|
1179
|
+
while (view && ['running', 'queued'].includes(view.status) && Date.now() < until && !signal?.aborted) {
|
|
1180
|
+
await settled(null, Math.min(1000, until - Date.now()), signal);
|
|
1085
1181
|
view = read();
|
|
1086
1182
|
}
|
|
1087
1183
|
return view;
|
|
@@ -1182,7 +1278,10 @@ export class JobManager {
|
|
|
1182
1278
|
limits: live || !full ? null : limitsSummary(),
|
|
1183
1279
|
};
|
|
1184
1280
|
// The side-by-side gets what the rest of the response leaves of GROUP_CHARS.
|
|
1185
|
-
|
|
1281
|
+
// While no one has finished there is nothing to set side by side, and an
|
|
1282
|
+
// empty column per consultant is only context spent on every poll.
|
|
1283
|
+
const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
|
|
1284
|
+
if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
|
|
1186
1285
|
return view;
|
|
1187
1286
|
}
|
|
1188
1287
|
|
|
@@ -1192,18 +1291,15 @@ export class JobManager {
|
|
|
1192
1291
|
return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
|
|
1193
1292
|
}
|
|
1194
1293
|
|
|
1195
|
-
async waitGroup(groupId, waitMs, opts) {
|
|
1294
|
+
async waitGroup(groupId, waitMs, opts, signal) {
|
|
1196
1295
|
const group = this.groups.get(groupId);
|
|
1197
1296
|
// A fan-out from another process or an earlier session: nothing here to
|
|
1198
1297
|
// wait on, so report what the history holds.
|
|
1199
|
-
if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs);
|
|
1298
|
+
if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs, signal);
|
|
1200
1299
|
const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
|
|
1201
1300
|
if (budget > 0) {
|
|
1202
1301
|
const promises = group.job_ids.map((jid) => this.jobs.get(jid)?.promise).filter(Boolean);
|
|
1203
|
-
await Promise.
|
|
1204
|
-
Promise.all(promises),
|
|
1205
|
-
new Promise((r) => setTimeout(r, budget).unref?.()),
|
|
1206
|
-
]);
|
|
1302
|
+
await settled(Promise.all(promises), budget, signal);
|
|
1207
1303
|
}
|
|
1208
1304
|
return this.groupView(groupId, opts);
|
|
1209
1305
|
}
|
|
@@ -1321,6 +1417,36 @@ function partialOutput(run) {
|
|
|
1321
1417
|
};
|
|
1322
1418
|
}
|
|
1323
1419
|
|
|
1420
|
+
// Waits for `promise` (null: for nothing), at most `ms`, and no longer than
|
|
1421
|
+
// `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
|
|
1422
|
+
// cut short leaves nothing behind.
|
|
1423
|
+
async function settled(promise, ms, signal) {
|
|
1424
|
+
let timer;
|
|
1425
|
+
let stop;
|
|
1426
|
+
const until = new Promise((resolve) => {
|
|
1427
|
+
stop = resolve;
|
|
1428
|
+
timer = setTimeout(resolve, ms);
|
|
1429
|
+
if (signal?.aborted) resolve();
|
|
1430
|
+
else signal?.addEventListener('abort', resolve, { once: true });
|
|
1431
|
+
});
|
|
1432
|
+
try {
|
|
1433
|
+
await (promise ? Promise.race([promise, until]) : until);
|
|
1434
|
+
} finally {
|
|
1435
|
+
clearTimeout(timer);
|
|
1436
|
+
signal?.removeEventListener('abort', stop);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function parses(text) {
|
|
1441
|
+
try {
|
|
1442
|
+
normalizeResult(text);
|
|
1443
|
+
return true;
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
if (err instanceof OutputError) return false;
|
|
1446
|
+
throw err;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1324
1450
|
function recordableIds(result) {
|
|
1325
1451
|
return [
|
|
1326
1452
|
...result.findings.map((f) => f.id),
|
package/src/policy.mjs
CHANGED
|
@@ -214,9 +214,10 @@ export const POLICY = Object.freeze({
|
|
|
214
214
|
// going beyond that is an operator decision with quota to match.
|
|
215
215
|
maxConcurrent: limit('SEVERALLY_MAX_CONCURRENT', cfgServer.max_concurrent, 9, 1, 9),
|
|
216
216
|
maxJobsRetained: limit('SEVERALLY_MAX_JOBS_RETAINED', cfgServer.max_jobs_retained, 200, 20, 2000),
|
|
217
|
-
// Upper bound for consult_get(wait_ms).
|
|
218
|
-
//
|
|
219
|
-
//
|
|
217
|
+
// Upper bound for consult_get(wait_ms). 45 s stays under the 60 s some MCP
|
|
218
|
+
// clients allow a tool call, so a long wait does not blow up as a client-side
|
|
219
|
+
// timeout while the consultation is still healthy; a client registration
|
|
220
|
+
// that is known to wait longer raises it (scripts/install.mjs).
|
|
220
221
|
maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
|
|
221
222
|
|
|
222
223
|
input: Object.freeze({
|
|
@@ -261,7 +262,7 @@ export const POLICY = Object.freeze({
|
|
|
261
262
|
* different request, not a slower one.
|
|
262
263
|
*/
|
|
263
264
|
export function timeoutMs(consultant) {
|
|
264
|
-
return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms,
|
|
265
|
+
return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
/**
|
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/schema.mjs
CHANGED
|
@@ -147,9 +147,10 @@ export class RequestError extends Error {
|
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
149
|
* Validate + normalise a consult request.
|
|
150
|
-
* `isFollowup` is decided by the caller (the job store resolves followup_to first)
|
|
150
|
+
* `isFollowup` is decided by the caller (the job store resolves followup_to first);
|
|
151
|
+
* `groupFollowup` when followup_to named a fan-out, whose members are the targets.
|
|
151
152
|
*/
|
|
152
|
-
export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
|
|
153
|
+
export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
|
|
153
154
|
const parsed = requestSchema.safeParse(raw);
|
|
154
155
|
if (!parsed.success) {
|
|
155
156
|
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
|
@@ -220,9 +221,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
|
|
|
220
221
|
'duplicate_targets',
|
|
221
222
|
);
|
|
222
223
|
}
|
|
223
|
-
if (isFollowup && unique.length > 1) {
|
|
224
|
+
if (isFollowup && !groupFollowup && unique.length > 1) {
|
|
224
225
|
throw new RequestError(
|
|
225
|
-
'a follow-up continues the exchange with one consultant; name exactly one target',
|
|
226
|
+
'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
227
|
'followup_fanout_not_allowed',
|
|
227
228
|
);
|
|
228
229
|
}
|