severally-mcp 2.1.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 (33) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +132 -65
  4. package/README.md +136 -69
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +12 -5
  10. package/docs/install.md +14 -5
  11. package/package.json +4 -2
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29493 -28302
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/install.mjs +90 -5
  26. package/src/consultants.mjs +31 -10
  27. package/src/jobs.mjs +123 -11
  28. package/src/policy.mjs +44 -7
  29. package/src/run.mjs +38 -5
  30. package/src/schema.mjs +30 -8
  31. package/src/server.mjs +70 -122
  32. package/src/status.mjs +63 -0
  33. package/src/store.mjs +44 -1
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).default([]),
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).default([]),
48
- artifacts: z.array(artifactSchema).max(L.artifactsMax).default([]),
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).default('on'),
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).default([]),
109
- constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).default([]),
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
@@ -157,6 +160,21 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
157
160
  throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
158
161
  }
159
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] ??= [];
160
178
  const hasSingle = req.target !== undefined && req.target !== null;
161
179
  const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
162
180
  if (hasSingle && hasMany) {
@@ -192,6 +210,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
192
210
  chosen = named.map((name) => {
193
211
  let r = resolveConsultant(name, reachable, POLICY.ranking);
194
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
+ }
195
216
  if (r.none) {
196
217
  throw new RequestError(`no consultant answers to ${JSON.stringify(name)}; available: ${listed()}`, 'unknown_target');
197
218
  }
@@ -278,7 +299,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
278
299
  if (req.context.facts.length === 0 && req.context.artifacts.length === 0 && req.context.expose_paths.length === 0
279
300
  && !req.context.expose_diff && !req.context.expose_from_parent) {
280
301
  throw new RequestError(
281
- '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' : ''),
282
304
  'context_required',
283
305
  );
284
306
  }
package/src/server.mjs CHANGED
@@ -4,12 +4,12 @@ import path from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { McpServer } from '@modelcontextprotocol/server';
6
6
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
7
- import { POLICY, VERDICTS, availableConsultants, configProblem, limitsSummary } from './policy.mjs';
7
+ import { POLICY, VERDICTS, availableConsultants, configProblem, configWarnings, limitsSummary } from './policy.mjs';
8
8
  import { requestSchema, RequestError } from './schema.mjs';
9
9
  import { JobManager, pointsOf } from './jobs.mjs';
10
10
  import { exportChain, ExportError } from './export.mjs';
11
11
 
12
- export const SEVERALLY_VERSION = '2.1.0';
12
+ export const SEVERALLY_VERSION = '2.3.1';
13
13
 
14
14
  // consult_get waits this long when no wait_ms is given: a call that forgot it
15
15
  // would otherwise return at once and be repeated. Up to 45 s, under the 60 s
@@ -70,87 +70,53 @@ Budget is 1 initial round plus at most ${POLICY.maxRounds - 1} follow-ups per ch
70
70
  spent only on the specific points where you and the consultant actually diverge. Agreement is not evidence:
71
71
  check the grounds behind a point before you adopt it, and record what you adopted, rejected or held, and why.`;
72
72
 
73
- const startDescription = `Start a consultation with another agent (or several, via targets). Returns a job_id (or a group_id for several) immediately; the work runs in the background.
73
+ const startDescription = `Start a consultation with another agent (or several, via targets). Returns a job_id (or a group_id for several) at once; the work runs in the background.
74
74
 
75
75
  target: which consultant to ask. This machine can reach (name: CLI, model):
76
76
  ${availableConsultants().map((c) => ` ${c.name}: ${c.cli}, ${c.model}`).join('\n') || ' (none -- no consultant CLI is installed)'}
77
- Pass the name the user used: a consultant's name, its CLI ("codex", "agy"), its vendor ("openai") or
78
- its model ("glm", "claude opus", "gpt-6.1"). A name that fits several consultants goes to the
79
- operator's ranking first, then to the higher tier (opus over sonnet) and the newer version; when
80
- nothing separates them it is refused with the candidates -- ask the user which one, do not guess.
81
- A consultant that is not listed above is refused up front, so do not retry it -- say which ones
82
- are available instead.
83
- Consulting your own CLI on a fresh session is encouraged -- it sheds this session's history and
84
- drift -- and the result is marked as same lineage.
85
- targets: ask several consultants the same question at once (mutually exclusive with target, no duplicates).
86
- targets: ["all"] asks every consultant listed above; "all" cannot be mixed with names.
87
- Every member gets the byte-identical brief (on a follow-up, plus its own earlier rounds) and one
88
- group_id; poll it with consult_get({ group_id }). Each member takes one slot of the concurrency cap,
89
- and a fan-out that would go over it is refused before any member starts.
90
- A follow-up (followup_to) to a job names that one consultant. followup_to a group_id, with no target or
91
- targets, asks every member of that fan-out the same new question, each on its own chain from its latest
92
- round; a member that is still running, never answered or has used its rounds is skipped and listed.
93
- caller: optional -- the CLI you are running in ("codex" / "claude-code" / "antigravity" / "opencode"), so the
94
- server can annotate a same-vendor consultation.
95
- caller_model: optional -- the model you are running on (e.g. "claude-opus-5"), self-declared and never checked.
96
- With it, a same-vendor caveat can say "same lineage, different model" and the record keeps who asked
97
- whom; it never changes which model the consultant runs.
98
- initiator: optional -- "user" when the user asked for this consultation; leave it out when you started it
99
- yourself. Self-declared, never checked, gates nothing; it lets the record tell the two apart.
100
- mode: optional. Left out, it is read from context: no proposal is explore, a proposal is review, a proposal
101
- with counterpoints is debate; a follow-up keeps its chain's mode. To gather opinions without showing
102
- yours, leave context.proposal empty.
103
- explore - hand over objective/constraints/facts and withhold your own preferred solution, to get independent
104
- options, alternative problem framings and blind spots. context.proposal MUST be empty on round 1.
105
- review - hand over your current proposal AND the reasoning behind it, to get weaknesses, counterexamples,
106
- concrete improvements and the conditions under which it holds. context.proposal is required.
107
- debate - hand over the disputed point, your position (context.proposal), the other side's claims
108
- (context.counterpoints) and the extra evidence, to get what would change the judgement, how to test
109
- it, and which disagreements remain. Both are required.
110
-
111
- The consultant starts in an empty working directory and is not told where your repository is, so it sees your
112
- code only if you hand it over in this call. When the question is about code in a repository on this machine,
113
- name the files it is about by absolute path in context.expose_paths: those files and directories are copied
114
- read-only into its working directory (under ./workspace) and are then the only part of your repository it has.
115
- At most 20 entries, 500 files and 5 MB in total; symlinks and protected files are skipped rather than copied,
116
- and text files are credential-masked exactly as the brief is. A directory inside a git work tree copies only the
117
- files git lists (tracked, or untracked and not ignored), so a repository root fits; outside git, .git and
118
- node_modules are skipped. A follow-up starts in a fresh working directory
119
- with no files: pass context.expose_from_parent: true to copy again, as they are now, the paths the round it follows
120
- exposed (a changed or vanished entry is marked; an earlier expose_diff brings back the files it touched, not the
121
- diff), and add expose_paths for anything new. It is never implied: leave it off when the follow-up does not need them. Put the facts it needs into context.facts, and keep
122
- context.artifacts for what is not a file here (test output, a log); an excerpt there goes alongside the
123
- exposed files, not instead of them. For a change you are making, pass
124
- context.expose_diff: { repo: "<absolute repository path>", base?: "<revision, default HEAD>" } instead or as
125
- well: the diff goes in the brief and every file it touches is copied under ./workspace/repo/ at its repository
126
- path. Model, permissions, round count, timeout and size
127
- caps are fixed by this server and cannot be raised from a request.
128
-
129
- network: optional -- "on" (default) or "off". "off" takes web search and browsing away from every consultant
130
- in this round: use it when the brief holds material that must not leave the machine through a URL.
131
- Whatever the setting, consultants are refused credential and history paths (~/.ssh, cloud keys, other
132
- tools' sessions, severally's own history, .env files) where the CLI can enforce it; each answer's
133
- \`boundary\` says what held for that consultant.
134
-
135
- prediction: optional -- { expected, worry }: the bottom line you expect back and, in one sentence, what you are
136
- most worried about. Stored with the consultation and NEVER sent to the consultant. It can only be
137
- written here, before the consultant runs, so that afterwards you cannot rewrite what you thought
138
- beforehand; consult_record takes the other half (reflection) once you have read the answer.`;
77
+ Pass the word the user used: a name, a CLI ("codex", "agy"), a vendor or a model ("claude opus"). One
78
+ that fits several, unresolved by the operator's ranking, tier and version, is refused with the
79
+ candidates (target_ambiguous); one not listed is refused (target_unavailable). Your own CLI on a fresh
80
+ session is a valid consultant, marked same lineage.
81
+ targets: several consultants, the same question (not with target, no duplicates); targets: ["all"] is every
82
+ consultant listed above. Every member gets the byte-identical brief (a follow-up adds its own earlier rounds) and one group_id, for consult_get
83
+ and consult_cancel. A fan-out over the concurrency cap is refused before any member starts.
84
+ followup_to: the job_id of the answer to continue, or a group_id to ask every member of that fan-out again
85
+ (each on its own chain; a member that cannot take one is skipped and listed). A follow-up needs only
86
+ followup_to and question: the consultant, mode, network, objective, constraints, success_criteria,
87
+ facts, proposal, counterpoints and artifacts are the earlier round's unless you pass them, and one you pass,
88
+ even empty, replaces it. Files are not carried over: context.expose_from_parent: true copies again,
89
+ as they are now, what the earlier round exposed. A job's follow-up cannot change the consultant.
90
+ mode: optional, read from context when left out: no proposal is explore, a proposal is review, a proposal with
91
+ counterpoints is debate. review and debate need context.proposal (your plan and its reasoning); debate
92
+ also needs context.counterpoints (the other side's claims); explore refuses a proposal on round 1.
93
+ context: the consultant starts in an empty working directory and is not told where your repository is, so
94
+ give it at least one of: facts, artifacts (material that is not a file here: test output, a log),
95
+ expose_paths (absolute paths copied read-only under ./workspace; a directory in a git work tree copies
96
+ the files git lists; at most 20 entries, 500 files, 5 MB; symlinks and protected files are skipped,
97
+ text is credential-masked like the brief), expose_diff: { repo, base? } (the diff in the brief, the
98
+ files it touches under ./workspace/repo/).
99
+ network: "on" (default) or "off" (no web search or browsing for any consultant this round). Credential and
100
+ history paths are refused either way where the CLI can enforce it; each answer's \`boundary\` says what held.
101
+ caller, caller_model: the CLI and model you run on (self-declared), for the same-vendor caveat and the record.
102
+ initiator: "user" when the user asked for this; leave it out when you started it yourself.
103
+ prediction: { expected, worry }, kept and never sent; it can only be written here, before the answer.
104
+ Model, permissions, rounds, timeout and size caps are fixed by this server.`;
139
105
 
140
106
  // One consultation's record: the single form of consult_record, and each
141
107
  // element of its `records`.
142
108
  const recordShape = {
143
- job_id: z.string().min(1).describe('the consultation to record against -- the job_id consult_get returned for it'),
109
+ job_id: z.string().min(1).describe('the job_id consult_get returned'),
144
110
  entries: z.array(z.object({
145
- id: z.string().min(1).describe('the point this verdict is about: f1, u1, c1 ... as returned in the result'),
111
+ id: z.string().min(1).describe('f1, u1, c1 ... from the result'),
146
112
  verdict: z.enum(VERDICTS),
147
- effect: z.string().max(4000).optional().describe('what it changed about your decision, or why it is still unverified'),
148
- note: z.string().max(4000).optional().describe('what you checked and what you found'),
113
+ effect: z.string().max(4000).optional().describe('what it changed, or why it is unverified'),
114
+ note: z.string().max(4000).optional().describe('what you checked and found'),
149
115
  })).min(1).max(100).optional(),
150
116
  reflection: z.object({
151
- delta: z.string().min(1).max(4000).describe('what the answer added over what you already expected -- including "nothing new", which is a real outcome'),
152
- related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('the points this is about: f1, u1, c1 ...'),
153
- }).optional().describe('written after you have read the answer. There is deliberately no hit/miss label: a point you predicted can still arrive with the evidence that settles it, and a surprise can still be wrong.'),
117
+ delta: z.string().min(1).max(4000).describe('what the answer added over what you expected ("nothing new" counts)'),
118
+ related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('f1, u1, c1 ...'),
119
+ }).optional().describe('written after reading the answer'),
154
120
  };
155
121
 
156
122
  export function createServer(manager = new JobManager()) {
@@ -192,24 +158,17 @@ export function createServer(manager = new JobManager()) {
192
158
  title: 'Get consultation status or result',
193
159
  annotations: { readOnlyHint: true, openWorldHint: false },
194
160
  description:
195
- `Fetch the state of a consultation. It waits up to ${DEFAULT_WAIT_MS} ms for it to finish before ` +
196
- 'answering, a limit set to stay under this client\'s tool-call timeout; wait_ms sets another wait, and ' +
197
- 'wait_ms: 0 answers at once. While it waits it sends a progress notification every 15 s if you asked ' +
198
- 'for them, and a call you cancel stops waiting. A typical consultation takes one to ten minutes; call ' +
199
- 'this again while it runs, and pass a shorter wait_ms when you have independent work to do in between. ' +
200
- 'A completed job carries the structured ' +
201
- 'answer; a failed one carries failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, ' +
202
- 'invalid_output, cli_error, spawn_error, interrupted) — that is "no advice was obtained", which is different from advice that arrived ' +
203
- 'with thin evidence (see quality.evidence_basis). ' +
204
- 'Pass group_id instead of job_id to fetch a fan-out; the wait then covers every consultant in it. ' +
205
- 'A fan-out comes back sized to read in one go: each member is its status, failure and job_id, and ' +
206
- '`comparison.by_target` holds every consultant\'s stance, summary and findings (with their ids) side by ' +
207
- 'side, cut to fixed lengths where `truncated` says so. consult_get({ job_id, ids: ["f2", "u1"] }) on a ' +
208
- 'member returns just those points, each whole with its grounds and your verdict (`recorded`): fetch every point you ' +
209
- 'mean to adopt, that decides your call, that rests on thin grounds, or that every consultant assumes -- ' +
210
- 'not only where they diverge. consult_get({ job_id }) returns the whole answer. A finished ' +
211
- 'consultation names its brief (size, sha256, round file) rather than repeating it; detail: "full" ' +
212
- 'returns the brief, and for a fan-out every member\'s full answer.',
161
+ `Fetch a consultation's state or answer. It waits up to ${DEFAULT_WAIT_MS} ms (under this client's tool-call ` +
162
+ 'timeout) for it to finish; wait_ms sets another wait, 0 answers at once. While waiting it sends a progress ' +
163
+ 'notification every 15 s when asked; a cancelled call stops waiting. Most take one to ten minutes: call ' +
164
+ 'again while status is running. completed carries the structured answer; failed carries ' +
165
+ 'failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, ' +
166
+ 'spawn_error, interrupted), meaning no advice was obtained -- not the same as advice with thin evidence ' +
167
+ '(quality.evidence_basis, quality.caveat). group_id fetches a fan-out: each member\'s status, failure and ' +
168
+ 'job_id, and `comparison.by_target` with every stance, summary and finding id side by side, cut where ' +
169
+ '`truncated` says so. ids: ["f2", "u1"] with a job_id returns just those points, whole, with their grounds ' +
170
+ 'and your verdict (`recorded`). A finished consultation names its brief rather than repeating it; detail: ' +
171
+ '"full" returns the brief, and for a fan-out every member\'s full answer.',
213
172
  inputSchema: z.object({
214
173
  job_id: z.string().min(1).optional(),
215
174
  group_id: z.string().min(1).optional(),
@@ -264,7 +223,8 @@ export function createServer(manager = new JobManager()) {
264
223
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
265
224
  description:
266
225
  'Stop a running consultation and kill the consultant process and everything it spawned. ' +
267
- 'Pass group_id instead of job_id to stop every consultant in a fan-out.',
226
+ 'Pass group_id instead of job_id to stop every consultant in a fan-out. One another severally server runs '
227
+ + 'is asked to stop, and that server stops it within a second.',
268
228
  inputSchema: z.object({
269
229
  job_id: z.string().min(1).optional(),
270
230
  group_id: z.string().min(1).optional(),
@@ -288,31 +248,21 @@ export function createServer(manager = new JobManager()) {
288
248
  title: 'Record what checking a point showed',
289
249
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
290
250
  description:
291
- 'Write your own verdict against one or more points of an answer, after you have checked them in the '
292
- + 'repository. Ids come from the result: findings are f1, f2 ..., unknowns u1 ..., next_checks c1 ... . '
293
- + 'verdict says what checking showed -- "confirmed" (it holds here), "not_applicable" (true in general, '
294
- + 'not for this codebase), "unverifiable" (cannot be settled with what you can reach), "unverified" (not '
295
- + 'checked yet, and say in effect why not). It does not say whether you adopted the point. effect is what '
296
- + 'it changed about your decision; note is the evidence you used. A follow-up to this job shows the '
297
- + 'consultant each point\'s verdict and effect (credential-masked, like the brief), never the note. '
298
- + 'Recording the same id again replaces '
299
- + 'that entry. This server stores what you write and counts the verdicts; it never infers one, and never '
300
- + 'decides a consultation was worth it. The entry is saved beside the answer and the brief in '
301
- + '~/.severally/history, which is what makes the decision readable a month from now; the job is '
302
- + 'read back from that history, so a consultation from an earlier session can still be recorded against. '
303
- + 'It answers with counts (the verdicts tallied, and how many points now carry one); consult_get reads '
304
- + 'the entries back. Pass `reflection` to record what the answer added over what you already expected, when the '
305
- + 'consultation was started with a `prediction`. A prediction itself cannot be written here: it goes '
306
- + 'in consult_start, before the consultant runs, which is the only thing that makes it a prediction. '
307
- + 'For a fan-out, record every member in one call: records: [{ job_id, entries, reflection? }, ...] in '
308
- + 'place of job_id/entries/reflection. Every record is checked first; if any job or id is wrong, nothing is '
309
- + 'written and each bad one is named. A job whose write fails is listed under results with its error, '
310
- + 'not counted as saved.',
251
+ 'Write your verdict on points of an answer after checking them: ids f1 ... (findings), u1 ... (unknowns), '
252
+ + 'c1 ... (next_checks). verdict is what checking showed -- confirmed (holds here), not_applicable (true in '
253
+ + 'general, not here), unverifiable, unverified (not checked yet; say why in effect) -- not whether you adopted '
254
+ + 'it. effect is what it changed about your decision, note the evidence. A follow-up shows the consultant each '
255
+ + 'verdict and effect (credential-masked), never the note. The same id again replaces the entry. Saved beside '
256
+ + 'the answer in ~/.severally/history, so a job from an earlier session can be recorded against; the server '
257
+ + 'never infers a verdict. Answers with counts; consult_get reads the entries back. reflection records what the '
258
+ + 'answer added over your consult_start prediction. records: [{ job_id, entries, reflection? }, ...] records '
259
+ + 'every member of a fan-out in one call; all are checked first and nothing is written if any job or id is '
260
+ + 'wrong. A job whose write fails is listed under results with its error.',
311
261
  inputSchema: z.object({
312
262
  ...recordShape,
313
263
  job_id: recordShape.job_id.optional(),
314
264
  records: z.array(z.object(recordShape)).min(1).max(POLICY.maxConcurrent).optional()
315
- .describe('several consultations at once -- one per member of a fan-out -- instead of job_id/entries/reflection'),
265
+ .describe('one per fan-out member, instead of job_id/entries/reflection'),
316
266
  }),
317
267
  },
318
268
  async ({ job_id, entries, reflection, records }) => {
@@ -342,12 +292,10 @@ export function createServer(manager = new JobManager()) {
342
292
  title: 'Export a consultation as a record to commit',
343
293
  annotations: { readOnlyHint: true, openWorldHint: false },
344
294
  description:
345
- 'Render one consultation (chain_id) or one fan-out (group_id) as Markdown: the brief as it was sent, '
346
- + 'each consultant\'s answer as it came back, and the verdicts recorded against each point -- with the '
347
- + 'ones nobody checked marked as unchecked. Nothing is summarised across consultants and nothing is '
348
- + 'scored. The text is returned, not written: put it wherever the decision belongs in the repository '
349
- + '(a decision record next to the code it is about), which is the only place a teammate will find it. '
350
- + 'Reads the on-disk history, so a consultation from an earlier session can still be exported.',
295
+ 'Render one consultation (chain_id) or fan-out (group_id) as Markdown: the brief as sent, each answer as '
296
+ + 'it came back, and the verdicts on each point, unchecked ones marked. Nothing is summarised or scored. '
297
+ + 'The text is returned, not written: put it beside the code the decision is about. Reads the history, so '
298
+ + 'an earlier session\'s consultation can be exported.',
351
299
  inputSchema: z.object({
352
300
  chain_id: z.string().min(1).optional(),
353
301
  group_id: z.string().min(1).optional(),
@@ -369,10 +317,9 @@ export function createServer(manager = new JobManager()) {
369
317
  title: 'List recent consultations',
370
318
  annotations: { readOnlyHint: true, openWorldHint: false },
371
319
  description:
372
- 'Recent consultations from this session, newest first, with their status, one-line summary and who '
373
- + 'asked for them (initiator), and usage_totals: the token (and, where the CLI reports one, cost) figures each consultant reported this '
374
- + 'session, summed per consultant over every attempt, automatic re-runs included. A figure a CLI does not '
375
- + 'report is not counted as zero; it is named under not_reported.',
320
+ 'Recent consultations, earlier sessions\' too (this_session is false), newest first, with their ids, '
321
+ + 'status, summary and verdicts; and usage_totals, the tokens (and cost) each consultant reported this session, '
322
+ + 'retries included. A figure a CLI does not report is named under not_reported, not counted as zero.',
376
323
  inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
377
324
  },
378
325
  async ({ limit }) => ok({
@@ -390,6 +337,7 @@ export async function main() {
390
337
  // consult_start: the operator may be the only one who reads stderr.
391
338
  const problem = configProblem();
392
339
  if (problem) process.stderr.write(`severally: config needs fixing; every consultation will be refused until it is -- ${problem}\n`);
340
+ for (const w of configWarnings()) process.stderr.write(`severally: config mistake; the consultant or CLI it is in stays unused until it is fixed -- ${w}\n`);
393
341
  if (availableConsultants().length === 0) {
394
342
  process.stderr.write(
395
343
  'severally: no consultant CLI found on PATH (codex / claude / agy / opencode); every consultation will be refused\n',
package/src/status.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // `severally-mcp status`: what this machine has set up, read without starting a
2
+ // consultation, so it spends no quota. It says what it can see and no more: a
3
+ // CLI being registered and installed does not show it is logged in, and that is
4
+ // reported as not checked rather than as working.
5
+ import { POLICY, configProblem, configWarnings, unavailableReason, consultantAvailable, brokenConsultantNames, brokenConsultant } from './policy.mjs';
6
+ import { SEVERALLY_VERSION, staleInstall } from './server.mjs';
7
+ import { findCommand, execCommandSync } from './platform.mjs';
8
+
9
+ // How each client lists severally, plugin installs first. A command that exits 0
10
+ // and names severally counts as registered.
11
+ const CLIENTS = [
12
+ { label: 'Claude Code', cmd: 'claude', checks: [['plugin', 'list'], ['mcp', 'get', 'severally']] },
13
+ { label: 'Codex', cmd: 'codex', checks: [['plugin', 'list'], ['mcp', 'get', 'severally']] },
14
+ { label: 'Antigravity', cmd: 'agy', checks: [['mcp', 'list']] },
15
+ { label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
16
+ ];
17
+
18
+ // "registered": the client's own listing names severally (it may still be
19
+ // turned off there). "not registered": the client answered, without it.
20
+ // No answer at all (a timeout, a failure to start): unknown.
21
+ function registration({ cmd, checks }) {
22
+ if (!findCommand(cmd)) return 'not installed';
23
+ let ran = false;
24
+ for (const args of checks) {
25
+ try {
26
+ const out = execCommandSync(cmd, args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ ran = true;
28
+ if (/(^|[\s@:])severally([\s@:]|$)/m.test(out)) return 'registered (listed by the client)';
29
+ } catch (err) {
30
+ // It ran and said no (`mcp get` of a missing server exits non-zero); a
31
+ // timeout or a failure to start says nothing either way.
32
+ if (typeof err.status === 'number') ran = true;
33
+ }
34
+ }
35
+ return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
36
+ }
37
+
38
+ export function status() {
39
+ const lines = [`severally ${SEVERALLY_VERSION}`, ''];
40
+ const problem = configProblem();
41
+ lines.push(`config: ${problem ? `needs fixing, every consultation is refused until it is -- ${problem}` : 'ok'}`);
42
+ for (const w of configWarnings()) lines.push(` mistake (only that consultant or CLI is out of use): ${w}`);
43
+ lines.push(`network: ${POLICY.network === 'off' ? 'off for every consultation (server.network)' : 'per request'}`, '');
44
+
45
+ lines.push('consultants:');
46
+ for (const c of POLICY.consultants) {
47
+ lines.push(` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`);
48
+ }
49
+ for (const name of brokenConsultantNames()) lines.push(` ${name}: not available: ${brokenConsultant(name)}`);
50
+ lines.push('');
51
+
52
+ const stale = staleInstall();
53
+ lines.push(stale
54
+ ? `copies: ${Object.entries(stale.copies).map(([k, v]) => `${k} ${v}${stale.behind.includes(k) ? ' (behind)' : ''}`).join(', ')}\n ${stale.fix}`
55
+ : 'copies: every installed copy is this version', '');
56
+
57
+ lines.push('clients:');
58
+ for (const client of CLIENTS) lines.push(` ${client.label.padEnd(12)} ${registration(client)}`);
59
+ lines.push('', 'login: not checked -- an installed, registered CLI may still be logged out or out of quota; '
60
+ + 'the first consultation is what shows it');
61
+ process.stdout.write(`${lines.join('\n')}\n`);
62
+ return problem ? 1 : 0;
63
+ }
package/src/store.mjs CHANGED
@@ -63,6 +63,21 @@ function indexRow(jobId) {
63
63
  return null;
64
64
  }
65
65
 
66
+ /** Every row of the history index, in the order written; a damaged line is skipped. */
67
+ export function indexRows() {
68
+ return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n').flatMap((line) => {
69
+ try {
70
+ const row = line.trim() ? JSON.parse(line) : null;
71
+ return row && typeof row === 'object' && JOB_ID.test(row.job_id) ? [row] : [];
72
+ } catch { return []; }
73
+ });
74
+ }
75
+
76
+ /** The round file an index row points at, or null. */
77
+ export function loadRoundAt(row) {
78
+ try { return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8')); } catch { return null; }
79
+ }
80
+
66
81
  export const roundPath = (chainId, round) =>
67
82
  path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
68
83
 
@@ -199,13 +214,28 @@ function withSeatLock(fn) {
199
214
  export function takeSeats(seats, cap, held) {
200
215
  return withSeatLock(() => {
201
216
  const live = [];
202
- for (const name of fs.readdirSync(seatsDir())) {
217
+ const names = fs.readdirSync(seatsDir());
218
+ // A cancel asked for after its consultation ended has no seat left to go with.
219
+ for (const name of names.filter((n) => n.endsWith('.cancel'))) {
220
+ if (!names.includes(name.replace(/\.cancel$/, '.json'))) try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
221
+ }
222
+ for (const name of names) {
203
223
  if (!name.endsWith('.json')) continue;
204
224
  let seat = null;
205
225
  try { seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8')); } catch { /* half-written or damaged: free */ }
206
226
  if (seat && held(seat)) live.push(seat);
207
227
  else try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
208
228
  }
229
+ // A chain runs one round at a time, across processes: a live seat on the
230
+ // same chain, or a round file already at the number chosen, means another
231
+ // server continued it first.
232
+ for (const mine of seats.filter((x) => x.chain_id)) {
233
+ let other = live.find((x) => x.chain_id === mine.chain_id);
234
+ if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
235
+ try { other = { job_id: JSON.parse(fs.readFileSync(roundPath(mine.chain_id, mine.round), 'utf8')).job_id, round: mine.round }; } catch { other = { job_id: '(unreadable)', round: mine.round }; }
236
+ }
237
+ if (other) return { ok: false, chainBusy: { live: other, mine } };
238
+ }
209
239
  if (live.length + seats.length > cap) {
210
240
  return { ok: false, live: live.length, elsewhere: live.filter((x) => x.owner?.pid !== process.pid).length };
211
241
  }
@@ -220,6 +250,19 @@ export function takeSeats(seats, cap, held) {
220
250
  export function releaseSeat(jobId) {
221
251
  if (!JOB_ID.test(jobId)) return;
222
252
  try { fs.rmSync(seatFile(jobId), { force: true }); } catch { /* best effort */ }
253
+ clearCancel(jobId);
254
+ }
255
+
256
+ // A cancel asked for by a server that does not run the consultation: left
257
+ // beside its seat, for the server that does to find and act on.
258
+ const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
259
+ export function requestCancel(jobId) {
260
+ if (!JOB_ID.test(jobId)) return false;
261
+ try { fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync(cancelFile(jobId), '', { mode: 0o600 }); return true; } catch { return false; }
262
+ }
263
+ export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
264
+ export function clearCancel(jobId) {
265
+ if (JOB_ID.test(jobId)) try { fs.rmSync(cancelFile(jobId), { force: true }); } catch { /* best effort */ }
223
266
  }
224
267
 
225
268
  export function loadGroup(groupId) {