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/consultants.mjs
CHANGED
|
@@ -212,25 +212,41 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
|
212
212
|
* CLI's availability, decided by policy.mjs.
|
|
213
213
|
*/
|
|
214
214
|
export function buildConsultants(config) {
|
|
215
|
+
// `problems` is every mistake, for the operator to read; `global` the ones
|
|
216
|
+
// that leave the whole file untrustworthy. A mistake inside one consultant
|
|
217
|
+
// or one CLI is kept to it: that consultant cannot be used (`broken`), that
|
|
218
|
+
// CLI is off (`brokenClis`) -- the closed side either way, so nothing the
|
|
219
|
+
// operator turned off comes back on.
|
|
215
220
|
const problems = [];
|
|
221
|
+
const global = [];
|
|
222
|
+
const broken = {};
|
|
223
|
+
const brokenClis = {};
|
|
224
|
+
const fault = (msg) => { problems.push(msg); global.push(msg); };
|
|
216
225
|
const cfg = isObject(config) ? config : {};
|
|
217
|
-
if (!isObject(config))
|
|
226
|
+
if (!isObject(config)) fault('the config file must hold a JSON object');
|
|
218
227
|
for (const key of Object.keys(cfg)) {
|
|
219
|
-
if (!TOP_KEYS.includes(key))
|
|
228
|
+
if (!TOP_KEYS.includes(key)) fault(`unknown top-level key "${key}" (allowed: ${TOP_KEYS.join(', ')})`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The one server key that is not a number to clamp: a wrong value cannot
|
|
232
|
+
// be read as either setting, and reading it as "on" would open the web.
|
|
233
|
+
if (isObject(cfg.server) && cfg.server.network !== undefined && !['on', 'off'].includes(cfg.server.network)) {
|
|
234
|
+
fault('server.network must be "on" or "off"');
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
let ranking = [];
|
|
223
238
|
if (cfg.ranking !== undefined) {
|
|
224
239
|
if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== '')) ranking = cfg.ranking.map((r) => r.trim());
|
|
225
|
-
else
|
|
240
|
+
else fault('ranking must be an array of non-empty strings');
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
if (cfg.clis !== undefined) {
|
|
229
|
-
if (!isObject(cfg.clis))
|
|
244
|
+
if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
|
|
230
245
|
else {
|
|
231
246
|
for (const [id, def] of Object.entries(cfg.clis)) {
|
|
232
247
|
if (!CLI_IDS.includes(id)) { problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`); continue; }
|
|
233
|
-
|
|
248
|
+
const before = problems.length;
|
|
249
|
+
if (!isObject(def)) { problems.push(`clis.${id} must be an object`); brokenClis[id] = problems.slice(before); continue; }
|
|
234
250
|
for (const k of Object.keys(def)) if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
|
|
235
251
|
// A wrong type must not fall back to a default: enabled: "false"
|
|
236
252
|
// read as "unset" would turn on the CLI the operator meant to stop.
|
|
@@ -242,6 +258,7 @@ export function buildConsultants(config) {
|
|
|
242
258
|
if (id !== 'opencode') problems.push(`clis.${id}.provider: only OpenCode takes provider definitions`);
|
|
243
259
|
else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject))) problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
|
|
244
260
|
}
|
|
261
|
+
if (problems.length > before) brokenClis[id] = problems.slice(before);
|
|
245
262
|
}
|
|
246
263
|
}
|
|
247
264
|
}
|
|
@@ -250,15 +267,15 @@ export function buildConsultants(config) {
|
|
|
250
267
|
if (cfg.consultants === undefined) {
|
|
251
268
|
for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
|
|
252
269
|
} else if (!isObject(cfg.consultants)) {
|
|
253
|
-
|
|
270
|
+
fault('consultants must be an object keyed by name');
|
|
254
271
|
} else {
|
|
255
272
|
const seen = new Map();
|
|
256
273
|
for (const [name, def] of Object.entries(cfg.consultants)) {
|
|
257
274
|
const at = `consultants.${name}`;
|
|
258
275
|
if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) { problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`); continue; }
|
|
259
276
|
if (RESERVED_NAMES.includes(name)) { problems.push(`${at}: "${name}" is reserved`); continue; }
|
|
260
|
-
if (!isObject(def)) { problems.push(`${at} must be an object`); continue; }
|
|
261
277
|
const before = problems.length;
|
|
278
|
+
if (!isObject(def)) { problems.push(`${at} must be an object`); broken[name] = problems.slice(before); continue; }
|
|
262
279
|
for (const k of Object.keys(def)) if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
|
|
263
280
|
if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
|
|
264
281
|
if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
|
|
@@ -272,14 +289,18 @@ export function buildConsultants(config) {
|
|
|
272
289
|
if (def.max_budget_usd !== undefined && def.cli !== 'claude-code') problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
|
|
273
290
|
// agy takes the effort from the model name and rejects --effort.
|
|
274
291
|
if (def.effort !== undefined && def.cli === 'antigravity') problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
|
|
275
|
-
if (problems.length > before) continue;
|
|
292
|
+
if (problems.length > before) { broken[name] = problems.slice(before); continue; }
|
|
276
293
|
const pair = `${def.cli}\n${def.model.trim()}`;
|
|
277
|
-
if (seen.has(pair)) {
|
|
294
|
+
if (seen.has(pair)) {
|
|
295
|
+
problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
|
|
296
|
+
broken[name] = problems.slice(before);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
278
299
|
seen.set(pair, name);
|
|
279
300
|
consultants.push(make(name, def));
|
|
280
301
|
}
|
|
281
302
|
}
|
|
282
|
-
return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems };
|
|
303
|
+
return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
|
|
283
304
|
}
|
|
284
305
|
|
|
285
306
|
function make(name, def) {
|
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(),
|