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.
Files changed (40) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +13 -7
  3. package/README.ja.md +157 -77
  4. package/README.md +162 -81
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +11 -3
  8. package/docs/how-it-works.md +12 -4
  9. package/docs/install.ja.md +25 -6
  10. package/docs/install.md +30 -7
  11. package/package.json +5 -3
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +4 -2
  14. package/plugins/severally/.mcp.json +2 -1
  15. package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
  16. package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
  17. package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
  18. package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
  19. package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
  20. package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
  21. package/plugins/severally/skills/claude/severally/reference.md +22 -10
  22. package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
  23. package/plugins/severally/skills/codex/severally/reference.md +22 -10
  24. package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
  25. package/plugins/severally/skills/opencode/severally/reference.md +22 -10
  26. package/scripts/init-config.mjs +1 -1
  27. package/scripts/install.mjs +124 -9
  28. package/src/adapters/antigravity-sandbox.mjs +5 -1
  29. package/src/adapters/codex.mjs +17 -6
  30. package/src/brief.mjs +38 -16
  31. package/src/consultants.mjs +31 -10
  32. package/src/expose-paths.mjs +68 -5
  33. package/src/jobs.mjs +294 -56
  34. package/src/policy.mjs +49 -11
  35. package/src/redact.mjs +20 -1
  36. package/src/run.mjs +38 -5
  37. package/src/schema.mjs +35 -12
  38. package/src/server.mjs +96 -120
  39. package/src/status.mjs +63 -0
  40. package/src/store.mjs +44 -1
@@ -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)) problems.push('the config file must hold a JSON object');
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)) problems.push(`unknown top-level key "${key}" (allowed: ${TOP_KEYS.join(', ')})`);
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 problems.push('ranking must be an array of non-empty strings');
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)) problems.push('clis must be an object keyed by CLI');
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
- if (!isObject(def)) { problems.push(`clis.${id} must be an object`); continue; }
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
- problems.push('consultants must be an object keyed by name');
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)) { problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`); continue; }
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) {
@@ -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
- walkDir(source, source, (abs, rel) => {
215
+ const add = (abs, rel) => {
152
216
  paths.push(rel.split(path.sep).join('/'));
153
217
  onFile(abs, rel, index, source, totals);
154
- }, totals, skipped);
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
- // Indexed, so two entries whose basenames collide (two different
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(),