amicus 3.2.2 → 4.0.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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +78 -0
  3. package/README.md +15 -3
  4. package/electron/main.js +4 -1
  5. package/package.json +3 -1
  6. package/schemas/abort.schema.json +17 -0
  7. package/schemas/alias-audit.schema.json +17 -0
  8. package/schemas/council-run.schema.json +37 -0
  9. package/schemas/council-stats.schema.json +28 -0
  10. package/schemas/council-tally.schema.json +70 -0
  11. package/schemas/council-validate.schema.json +22 -0
  12. package/schemas/council-verdict.schema.json +47 -0
  13. package/schemas/doctor.schema.json +29 -0
  14. package/schemas/error.schema.json +23 -0
  15. package/schemas/model-catalog.schema.json +19 -0
  16. package/schemas/run.schema.json +26 -0
  17. package/schemas/spend.schema.json +16 -0
  18. package/schemas/wave.schema.json +33 -0
  19. package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
  20. package/skills/second-opinion/SKILL.md +8 -0
  21. package/src/cli-handlers-abort.js +29 -0
  22. package/src/cli-handlers-council-run.js +168 -0
  23. package/src/cli-handlers-council.js +8 -5
  24. package/src/cli-handlers-status.js +35 -4
  25. package/src/cli.js +9 -0
  26. package/src/council/anonymize.js +76 -0
  27. package/src/council/briefings-stage2.js +150 -0
  28. package/src/council/briefings.js +141 -0
  29. package/src/council/findings.js +13 -1
  30. package/src/council/ledger.js +13 -1
  31. package/src/council/parse-stage2.js +103 -0
  32. package/src/council/run-assemble.js +100 -0
  33. package/src/council/run-launch.js +99 -0
  34. package/src/council/run-stages.js +187 -0
  35. package/src/council/run-state.js +122 -0
  36. package/src/council/run.js +269 -0
  37. package/src/council/tally.js +3 -1
  38. package/src/council/verdict.js +9 -2
  39. package/src/headless.js +24 -25
  40. package/src/mcp-council-run.js +267 -0
  41. package/src/mcp-server.js +87 -28
  42. package/src/mcp-tools.js +50 -0
  43. package/src/prompt-builder.js +36 -19
  44. package/src/sidecar/electron-lock.js +4 -1
  45. package/src/sidecar/fanout-leg.js +2 -2
  46. package/src/sidecar/fanout.js +1 -1
  47. package/src/sidecar/resume.js +7 -2
  48. package/src/utils/abort-result.js +1 -1
  49. package/src/utils/error-doc.js +2 -0
  50. package/src/utils/fold-marker.js +21 -0
  51. package/src/utils/route-error.js +26 -0
  52. package/src/utils/start-helpers.js +19 -10
  53. package/src/utils/untrusted-fence.js +8 -7
@@ -0,0 +1,267 @@
1
+ // src/mcp-council-run.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module mcp-council-run
6
+ * MCP surface for headless council runs (spec §8): the amicus_council_run
7
+ * handler (15th tool, born-fenced) plus the council-awareness helpers that
8
+ * amicus_status / amicus_list / amicus_abort call through the sessions-dir
9
+ * pointer file. Lives outside mcp-server.js (grandfathered-oversized); the
10
+ * spawn helper is INJECTED by mcp-server at call time to avoid a require
11
+ * cycle.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const runState = require('./council/run-state');
17
+ const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
+ const { RUNNING_VERSION } = require('./utils/version-info');
19
+ const { isPathInside } = require('./project-root-allowlist');
20
+
21
+ function textResult(text, isError) {
22
+ const result = { content: [{ type: 'text', text }] };
23
+ if (isError) { result.isError = true; }
24
+ return result;
25
+ }
26
+
27
+ /** Resolve the bench: models XOR council preset (amicus_fanout parity). */
28
+ function resolveBenchInput(input) {
29
+ const inputModels = Array.isArray(input.models) ? input.models : [];
30
+ const hasModels = inputModels.length > 0;
31
+ const hasCouncil = typeof input.council === 'string' && input.council.trim();
32
+ if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
33
+ if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
34
+ if (hasCouncil) {
35
+ const { resolveCouncilMembers } = require('./utils/config');
36
+ const { readCache } = require('./utils/model-catalog');
37
+ const catalog = (readCache() || {}).models || [];
38
+ const expanded = resolveCouncilMembers(input.council.trim(), catalog);
39
+ if (expanded.error) { return { error: expanded.error }; }
40
+ return { bench: expanded.models };
41
+ }
42
+ return { bench: inputModels };
43
+ }
44
+
45
+ /**
46
+ * amicus_council_run: validate → prep run dir → spawn CLI child → return
47
+ * {runId, runDir} immediately (fenced).
48
+ * @param {object} input tool input
49
+ * @param {string} project resolved project dir
50
+ * @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
51
+ */
52
+ async function handleCouncilRunTool(input, project, helpers) {
53
+ const CHAIR_DEFAULT = 'deepseek';
54
+ if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
55
+ return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
56
+ }
57
+ let briefing;
58
+ try { briefing = fs.readFileSync(input.briefingFile, 'utf-8'); }
59
+ catch (e) { return textResult(`Cannot read briefingFile ${input.briefingFile}: ${e.message}`, true); }
60
+ if (briefing.charCodeAt(0) === 0xFEFF) { briefing = briefing.slice(1); }
61
+ if (!briefing.trim()) { return textResult(`briefingFile ${input.briefingFile} is empty.`, true); }
62
+
63
+ const benchRes = resolveBenchInput(input);
64
+ if (benchRes.error) { return textResult(benchRes.error, true); }
65
+ const bench = benchRes.bench;
66
+ if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
67
+ const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
68
+ if (bench.includes(chair)) {
69
+ return textResult(`Chair '${chair}' is a bench seat — pick a chair outside the bench (default: ${CHAIR_DEFAULT}).`, true);
70
+ }
71
+ const critic = (typeof input.critic === 'string' && input.critic.trim()) ? input.critic.trim() : null;
72
+ if (critic && !bench.includes(critic)) {
73
+ return textResult(`Critic '${critic}' must be one of the bench seats (${bench.join(', ')}).`, true);
74
+ }
75
+ const lenses = Array.isArray(input.lenses) && input.lenses.length ? input.lenses : null;
76
+ if (critic && lenses) { return textResult('critic and lenses are mutually exclusive in v4.0.', true); }
77
+ if (lenses && lenses.length !== bench.length) {
78
+ return textResult(`lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length}).`, true);
79
+ }
80
+ if (input.timeoutMinutes !== undefined &&
81
+ (typeof input.timeoutMinutes !== 'number' || !Number.isFinite(input.timeoutMinutes) || input.timeoutMinutes <= 0)) {
82
+ return textResult('timeoutMinutes must be a positive number.', true);
83
+ }
84
+ if (input.maxCost !== undefined &&
85
+ (typeof input.maxCost !== 'number' || !Number.isFinite(input.maxCost) || input.maxCost <= 0)) {
86
+ return textResult('maxCost must be a positive number.', true);
87
+ }
88
+
89
+ const { generateTaskId } = require('./sidecar/start');
90
+ const runId = generateTaskId();
91
+ const runDir = input.outDir
92
+ ? path.resolve(project, String(input.outDir))
93
+ : path.join(project, `council-${runId}`);
94
+ if (!isPathInside(runDir, project)) {
95
+ return textResult(`outDir must resolve to a path inside the project directory (${project}).`, true);
96
+ }
97
+ const briefingPath = path.join(runDir, 'briefing.md');
98
+ try {
99
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
100
+ fs.writeFileSync(briefingPath, briefing, { mode: 0o600 });
101
+ runState.initRun(runDir, {
102
+ schemaVersion: 2, type: 'council-run', runId, status: 'running', stages: [],
103
+ bench, chair, critic, lenses, labelMap: null,
104
+ options: {
105
+ timeout: input.timeoutMinutes || null,
106
+ maxCost: (typeof input.maxCost === 'number') ? input.maxCost : null,
107
+ gateway: input.gateway || 'auto', outDir: runDir,
108
+ },
109
+ usage: null, createdAt: new Date().toISOString(),
110
+ });
111
+ runState.writePointer(project, runId, runDir);
112
+ } catch (err) {
113
+ return textResult(`Failed to prepare council run: ${err.message}`, true);
114
+ }
115
+
116
+ const args = [
117
+ 'council', 'run', '--prompt-file', briefingPath, '--run-id', runId,
118
+ '--out-dir', runDir, '--json', '--cwd', project,
119
+ '--models', bench.join(','), '--chair', chair,
120
+ '--client', helpers.clientName,
121
+ ];
122
+ if (critic) { args.push('--critic', critic); }
123
+ if (lenses) { args.push('--lenses', lenses.join(',')); }
124
+ if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
125
+ if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
126
+ if (input.gateway) { args.push('--gateway', input.gateway); }
127
+
128
+ try { helpers.spawnFn(args, runDir); } catch (err) {
129
+ try {
130
+ runState.checkpoint(runDir, { status: 'error', error: { code: 'INTERNAL', message: err.message }, completedAt: new Date().toISOString() });
131
+ } catch { /* best-effort */ }
132
+ return textResult(`Failed to start council run: ${err.message}`, true);
133
+ }
134
+
135
+ const body = JSON.stringify({
136
+ schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
137
+ message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
138
+ 'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
139
+ 'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
140
+ });
141
+ // Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
142
+ return textResult(fenceSidecarOutput(body));
143
+ }
144
+
145
+ /** ---- council-awareness helpers (consumed by mcp-server status/list/abort) ---- */
146
+
147
+ function elapsedOf(run) {
148
+ const end = run.completedAt || new Date().toISOString();
149
+ const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
150
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
151
+ }
152
+
153
+ /** Status payload for a council runId, or null when the id is not a council run. */
154
+ function buildCouncilStatusPayload(project, taskId) {
155
+ const ptr = runState.readPointer(project, taskId);
156
+ if (!ptr) { return null; }
157
+ const run = runState.readRun(ptr.runDir);
158
+ if (!run) { return null; }
159
+
160
+ // Crash detection: a running run.json whose engine pid is gone is 'error'.
161
+ if (run.status === 'running' && run.pid) {
162
+ try { process.kill(run.pid, 0); } catch (err) {
163
+ if (err.code !== 'EPERM') {
164
+ runState.checkpoint(ptr.runDir, {
165
+ status: 'error', completedAt: new Date().toISOString(),
166
+ error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
167
+ });
168
+ run.status = 'error';
169
+ run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
170
+ }
171
+ }
172
+ }
173
+
174
+ const stages = (run.stages || []).map(s => ({
175
+ name: s.name, status: s.status, waveId: s.waveId || null,
176
+ }));
177
+ const active = (run.stages || []).find(s => s.status === 'running') || null;
178
+ let legsTotal = null; let legsComplete = null;
179
+ if (active && active.waveId && active.project) {
180
+ try {
181
+ const { getSessionDir } = require('./session-manager');
182
+ const { TERMINAL_STATUSES } = require('./utils/result-schema');
183
+ const meta = JSON.parse(fs.readFileSync(
184
+ path.join(getSessionDir(active.project, active.waveId), 'metadata.json'), 'utf-8'));
185
+ const legs = meta.legs || [];
186
+ legsTotal = legs.length;
187
+ legsComplete = legs.filter((id) => {
188
+ try {
189
+ const m = JSON.parse(fs.readFileSync(
190
+ path.join(getSessionDir(active.project, id), 'metadata.json'), 'utf-8'));
191
+ return TERMINAL_STATUSES.includes(m.status);
192
+ } catch { return false; }
193
+ }).length;
194
+ } catch { /* stage wave not on disk yet */ }
195
+ }
196
+ const payload = {
197
+ taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
198
+ status: run.status, currentStage: active ? active.name : null, stages,
199
+ legsTotal, legsComplete, elapsed: elapsedOf(run),
200
+ exitCode: run.exitCode !== undefined ? run.exitCode : null,
201
+ version: RUNNING_VERSION,
202
+ };
203
+ if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
204
+ return payload;
205
+ }
206
+
207
+ /** amicus_list entries for every council pointer in the project. */
208
+ function listCouncilRuns(project) {
209
+ const { sanitizePreview } = require('./sidecar/progress-fields');
210
+ const out = [];
211
+ for (const ptr of runState.listPointers(project)) {
212
+ const run = runState.readRun(ptr.runDir);
213
+ if (!run) { continue; }
214
+ let briefing = '';
215
+ try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
216
+ catch { /* optional */ }
217
+ const active = (run.stages || []).find(s => s.status === 'running');
218
+ out.push({
219
+ id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
220
+ model: null, agent: 'Plan', createdAt: run.createdAt,
221
+ briefing: sanitizePreview(briefing, 80),
222
+ stage: active ? active.name : null,
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
230
+ * and cascade to the active stage's wave + legs so in-flight legs settle.
231
+ * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
232
+ */
233
+ function abortCouncilRun(project, taskId) {
234
+ const ptr = runState.readPointer(project, taskId);
235
+ if (!ptr) { return null; }
236
+ const run = runState.readRun(ptr.runDir);
237
+ if (!run) { return null; }
238
+ if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
239
+
240
+ const { markAborted } = require('./utils/session-abort');
241
+ const { getSessionDir } = require('./session-manager');
242
+ let cascaded = 0;
243
+ for (const s of run.stages || []) {
244
+ if (s.status !== 'running' || !s.waveId || !s.project) { continue; }
245
+ try {
246
+ const waveDir = getSessionDir(s.project, s.waveId);
247
+ let meta = {};
248
+ try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
249
+ catch { /* wave record may not exist yet */ }
250
+ for (const legId of meta.legs || []) {
251
+ try { if (markAborted(getSessionDir(s.project, legId), 'council abort')) { cascaded++; } }
252
+ catch { /* skip leg */ }
253
+ }
254
+ markAborted(waveDir, 'council abort');
255
+ } catch { /* skip stage */ }
256
+ }
257
+ runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
258
+ if (run.pid) {
259
+ try { require('./utils/abort-coordinator').waitThenKill(run.pid).catch(() => {}); }
260
+ catch { /* best-effort */ }
261
+ }
262
+ return { aborted: true, cascaded };
263
+ }
264
+
265
+ module.exports = {
266
+ handleCouncilRunTool, buildCouncilStatusPayload, listCouncilRuns, abortCouncilRun,
267
+ };
package/src/mcp-server.js CHANGED
@@ -198,6 +198,18 @@ function appendVersionWarning(content) {
198
198
  return content;
199
199
  }
200
200
 
201
+ /**
202
+ * v4.0 §7: stamp the additive {schemaVersion, type} envelope keys onto an MCP
203
+ * JSON success body. `type` reflects the doc's subject family — 'run' for
204
+ * session status/acks, 'wave' for wave status + fanout acks, 'abort' for abort
205
+ * acks. Published result-doc schemas (schemas/) describe the durable --json
206
+ * docs; these snapshots/acks share the family type names (docs/schemas.md).
207
+ */
208
+ function stampEnvelope(type, body) {
209
+ const { SCHEMA_VERSION } = require('./utils/result-schema-version');
210
+ return { schemaVersion: SCHEMA_VERSION, type, ...body };
211
+ }
212
+
201
213
  /**
202
214
  * Compute next poll hint for headless sessions.
203
215
  * @returns {{ hint: string }}
@@ -244,9 +256,17 @@ const handlers = {
244
256
  const { validateStartInputs } = require('./utils/input-validators');
245
257
  const validation = validateStartInputs(input);
246
258
  if (!validation.valid) {
259
+ // v4.0 §7: error-doc-shaped tool text (was the raw validation_error object).
260
+ const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
261
+ const verr = validation.error;
247
262
  return {
248
263
  isError: true,
249
- content: [{ type: 'text', text: JSON.stringify(validation.error) }],
264
+ content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({
265
+ code: verr.field === 'prompt' ? ERROR_CODES.MISSING_PROMPT : ERROR_CODES.BAD_ARGS,
266
+ message: `${verr.field}: ${verr.message}`,
267
+ hint: Array.isArray(verr.suggestions) && verr.suggestions.length
268
+ ? `Valid values: ${verr.suggestions.join(', ')}` : null,
269
+ })) }],
250
270
  };
251
271
  }
252
272
 
@@ -263,7 +283,7 @@ const handlers = {
263
283
  // resolveLaunchModel (start-helpers.js) via model-input-default.js.
264
284
  const { resolveGatewayMode } = require('./utils/config');
265
285
  const { resolveRouteForLaunch } = require('./utils/route-launch');
266
- const { toStructuredError } = require('./utils/route-error');
286
+ const { toErrorDocFields } = require('./utils/route-error');
267
287
  const { resolveModelInputOrDefault } = require('./utils/model-input-default');
268
288
 
269
289
  const modelInput = resolveModelInputOrDefault(input.model);
@@ -277,9 +297,10 @@ const handlers = {
277
297
  validateModel: true,
278
298
  });
279
299
  if (routeResult.kind !== 'resolved') {
300
+ const { buildErrorDoc } = require('./utils/error-doc');
280
301
  return {
281
302
  isError: true,
282
- content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
303
+ content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(toErrorDocFields(routeResult))) }],
283
304
  };
284
305
  }
285
306
  const resolvedModel = routeResult.executableId;
@@ -472,10 +493,10 @@ const handlers = {
472
493
  });
473
494
 
474
495
  // Return immediately
475
- const body = JSON.stringify({
496
+ const body = JSON.stringify(stampEnvelope('run', {
476
497
  taskId, status: 'running', mode: 'headless',
477
498
  message: 'Amicus started in headless mode. Use amicus_status to check progress.',
478
- });
499
+ }));
479
500
  // FIX 2 (#61 whole-branch review): surface the router's one-shot
480
501
  // migration notice here too — resolveRouteForLaunch already burned
481
502
  // the migration_notified flag for this vendor when it built
@@ -532,7 +553,7 @@ const handlers = {
532
553
  "Tell the user: 'Let me know when you're done with the session and have clicked Fold.' " +
533
554
  'Then wait for the user to tell you. Use amicus_read to get results once they confirm.';
534
555
 
535
- const body = JSON.stringify({ taskId, status: 'running', mode, message });
556
+ const body = JSON.stringify(stampEnvelope('run', { taskId, status: 'running', mode, message }));
536
557
  // FIX 2 (#61 whole-branch review): the spawn path never touches stderr of
537
558
  // the CLI child that will do the routing print — this handler already
538
559
  // resolved the route in-process above, so surface its notice here.
@@ -550,6 +571,17 @@ const handlers = {
550
571
  const sessionDir = safeSessionDir(cwd, input.taskId);
551
572
  const metadata = readMetadata(input.taskId, cwd);
552
573
  if (!metadata) {
574
+ // Council runs live behind sessions-dir pointer files, not session dirs
575
+ // (v4.0 spec §8). Resolve them before failing.
576
+ const council = require('./mcp-council-run').buildCouncilStatusPayload(cwd, input.taskId);
577
+ if (council) {
578
+ const content = [{ type: 'text', text: JSON.stringify(council) }];
579
+ appendVersionWarning(content);
580
+ if (council.status === 'running') {
581
+ content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
582
+ }
583
+ return { content };
584
+ }
553
585
  return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
554
586
  'If you ran it in a different project, pass the original "project".', true);
555
587
  }
@@ -608,12 +640,12 @@ const handlers = {
608
640
  }
609
641
 
610
642
  const ms = elapsedMs(metadata);
611
- const response = {
612
- taskId: metadata.taskId, type: 'wave', status: metadata.status,
643
+ const response = stampEnvelope('wave', {
644
+ taskId: metadata.taskId, status: metadata.status,
613
645
  legsComplete: done, legsTotal: legs.length, legs,
614
646
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
615
647
  version: RUNNING_VERSION,
616
- };
648
+ });
617
649
  if (metadata.status === 'crashed' || metadata.status === 'error') {
618
650
  response.reason = metadata.reason || 'Unknown error';
619
651
  }
@@ -641,11 +673,11 @@ const handlers = {
641
673
  }
642
674
 
643
675
  const ms = elapsedMs(metadata);
644
- const response = {
676
+ const response = stampEnvelope('run', {
645
677
  taskId: metadata.taskId, status: metadata.status,
646
678
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
647
679
  version: RUNNING_VERSION,
648
- };
680
+ });
649
681
  if (metadata.model) { response.model = metadata.model; }
650
682
 
651
683
  // F6: agent-visible mode (headless|interactive). metadata.mode is written at
@@ -817,7 +849,10 @@ const handlers = {
817
849
  }
818
850
  }
819
851
 
820
- let sessions = Array.from(byId.values())
852
+ // v4.0 §8: council runs are pointer files in the same sessions root — merge
853
+ // them as first-class rows (type 'council-run') before sorting/filtering.
854
+ const councilRows = require('./mcp-council-run').listCouncilRuns(cwd);
855
+ let sessions = Array.from(byId.values()).concat(councilRows)
821
856
  .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
822
857
 
823
858
  if (input.status && input.status !== 'all') {
@@ -837,10 +872,10 @@ const handlers = {
837
872
  try { spawnSidecarProcess(args, sessionDir); } catch (err) {
838
873
  return textResult(`Failed to resume: ${err.message}`, true);
839
874
  }
840
- return textResult(JSON.stringify({
875
+ return textResult(JSON.stringify(stampEnvelope('run', {
841
876
  taskId: input.taskId, status: 'running',
842
877
  message: 'Session resumed. Use amicus_status to check progress.',
843
- }));
878
+ })));
844
879
  },
845
880
 
846
881
  async amicus_continue(input, project, mcpServer) {
@@ -857,7 +892,7 @@ const handlers = {
857
892
  if (input.model) {
858
893
  const { resolveGatewayMode } = require('./utils/config');
859
894
  const { resolveRouteForLaunch } = require('./utils/route-launch');
860
- const { toStructuredError } = require('./utils/route-error');
895
+ const { toErrorDocFields } = require('./utils/route-error');
861
896
  const { resolveModelInputOrDefault } = require('./utils/model-input-default');
862
897
 
863
898
  const modelInput = resolveModelInputOrDefault(input.model);
@@ -869,9 +904,10 @@ const handlers = {
869
904
  validateModel: true,
870
905
  });
871
906
  if (routeResult.kind !== 'resolved') {
907
+ const { buildErrorDoc } = require('./utils/error-doc');
872
908
  return {
873
909
  isError: true,
874
- content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
910
+ content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(toErrorDocFields(routeResult))) }],
875
911
  };
876
912
  }
877
913
  resolvedModel = routeResult.executableId;
@@ -910,16 +946,29 @@ const handlers = {
910
946
  return textResult(`Failed to continue: ${err.message}`, true);
911
947
  }
912
948
  recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
913
- return textResult(JSON.stringify({
949
+ return textResult(JSON.stringify(stampEnvelope('run', {
914
950
  taskId: newTaskId, status: 'running',
915
951
  message: 'Continuation started. Use amicus_status to check progress.',
916
- }));
952
+ })));
917
953
  },
918
954
 
919
955
  async amicus_abort(input, project) {
920
956
  const cwd = project || getProjectDir(input.project);
921
957
  const metadata = readMetadata(input.taskId, cwd);
922
958
  if (!metadata) {
959
+ // Council runs resolve via the sessions-dir pointer file (v4.0 §8):
960
+ // mark run.json aborted + cascade to the active wave/leg records.
961
+ const council = require('./mcp-council-run').abortCouncilRun(cwd, input.taskId);
962
+ if (council) {
963
+ if (council.alreadyTerminal) {
964
+ return textResult(`Council run ${input.taskId} is not running (status: ${council.status}).`);
965
+ }
966
+ return textResult(JSON.stringify({
967
+ taskId: input.taskId, status: 'aborted', legsAborted: council.cascaded,
968
+ message: `Council run abort requested. ${council.cascaded} running leg(s) marked aborted; ` +
969
+ 'the engine process will finalize run.json as aborted shortly.',
970
+ }));
971
+ }
923
972
  return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
924
973
  'If you ran it in a different project, pass the original "project".', true);
925
974
  }
@@ -953,11 +1002,11 @@ const handlers = {
953
1002
  // if they outlive the grace window. Fire-and-forget — the tool result
954
1003
  // must not block on the grace period.
955
1004
  waitThenKill([metadata.pid, metadata.goPid]).catch(() => { /* best-effort */ });
956
- return textResult(JSON.stringify({
1005
+ return textResult(JSON.stringify(stampEnvelope('abort', {
957
1006
  taskId: input.taskId, status: 'aborted', legsAborted,
958
1007
  message: `Wave abort requested. ${legsAborted} running leg(s) marked aborted; ` +
959
1008
  'the fan-out process will terminate shortly.',
960
- }));
1009
+ })));
961
1010
  }
962
1011
 
963
1012
  // Single session: marker FIRST — the headless loop and the interactive
@@ -969,10 +1018,10 @@ const handlers = {
969
1018
  markAborted(sessionDir, 'manual abort (MCP)');
970
1019
  waitThenKill(metadata.pid).catch(() => { /* best-effort */ });
971
1020
 
972
- return textResult(JSON.stringify({
1021
+ return textResult(JSON.stringify(stampEnvelope('abort', {
973
1022
  taskId: input.taskId, status: 'aborted',
974
1023
  message: 'Session abort requested. The Amicus process will terminate shortly.',
975
- }));
1024
+ })));
976
1025
  },
977
1026
 
978
1027
  async amicus_fanout(input, project, mcpServer) {
@@ -1062,12 +1111,12 @@ const handlers = {
1062
1111
  return textResult(`Failed to start fan-out: ${err.message}`, true);
1063
1112
  }
1064
1113
 
1065
- const body = JSON.stringify({
1114
+ const body = JSON.stringify(stampEnvelope('wave', {
1066
1115
  waveId, taskIds: legIds, status: 'running', mode: 'headless',
1067
1116
  message: 'Fan-out started. Preferred: call amicus_wait with the waveId — one blocking call ' +
1068
1117
  'replaces polling; re-call it while it returns timedOut: true. Fallback: poll amicus_status ' +
1069
1118
  'with the waveId. Either way, amicus_read the waveId when complete.',
1070
- });
1119
+ }));
1071
1120
  return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
1072
1121
  },
1073
1122
 
@@ -1078,24 +1127,34 @@ const handlers = {
1078
1127
  // Auto-append to the reliability ledger (parity with `amicus council
1079
1128
  // tally`). Best-effort: a ledger write failure must not fail the tally.
1080
1129
  try { require('./council/ledger').appendRun(record); } catch { /* best-effort */ }
1081
- return textResult(JSON.stringify(record));
1130
+ // v4.0 §8 (H9): fence the JSON — council output summarizes untrusted
1131
+ // model prose entering the orchestrating agent's context. JSON intact
1132
+ // inside the fence; CLI --json stays unfenced (the programmatic channel).
1133
+ return textResult(fenceSidecarOutput(JSON.stringify(record)));
1082
1134
  } catch (err) { return textResult(`council tally failed: ${err.message}`, true); }
1083
1135
  },
1084
1136
 
1085
1137
  async amicus_council_stats() {
1086
1138
  try {
1087
- const { deriveReliability } = require('./council/ledger');
1088
- return textResult(JSON.stringify(deriveReliability()));
1139
+ const { deriveReliability, buildStatsDoc } = require('./council/ledger');
1140
+ return textResult(fenceSidecarOutput(JSON.stringify(buildStatsDoc(deriveReliability()))));
1089
1141
  } catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
1090
1142
  },
1091
1143
 
1092
1144
  async amicus_verdict(input) {
1093
1145
  try {
1094
1146
  const { buildVerdict } = require('./council/verdict');
1095
- return textResult(JSON.stringify(buildVerdict(input.record, input.decisions || [])));
1147
+ return textResult(fenceSidecarOutput(JSON.stringify(buildVerdict(input.record, input.decisions || []))));
1096
1148
  } catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
1097
1149
  },
1098
1150
 
1151
+ async amicus_council_run(input, project, mcpServer) {
1152
+ const cwd = project || getProjectDir(input.project);
1153
+ return require('./mcp-council-run').handleCouncilRunTool(input, cwd, {
1154
+ spawnFn: spawnSidecarProcess, clientName: detectClient(mcpServer),
1155
+ });
1156
+ },
1157
+
1099
1158
  async amicus_setup() {
1100
1159
  const { checkElectronAvailable } = require('./sidecar/interactive-process');
1101
1160
  if (!checkElectronAvailable()) {
package/src/mcp-tools.js CHANGED
@@ -421,6 +421,56 @@ function getTools() {
421
421
  project: z.string().optional().describe('Optional project directory path.'),
422
422
  },
423
423
  },
424
+ {
425
+ name: 'amicus_council_run',
426
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
427
+ description:
428
+ 'Run the FULL headless council engine: Stage-1 independent reviews → ' +
429
+ 'anonymized peer cross-review → deterministic tally → non-bench chair ' +
430
+ 'synthesis → verdict.json + report.html, without any orchestrating agent. ' +
431
+ 'Returns {runId, runDir} immediately (async). Preferred: call amicus_wait ' +
432
+ 'with the runId; re-call while it returns timedOut: true. Artifacts land ' +
433
+ 'in runDir. Exit semantics: run.json status complete (full run), partial ' +
434
+ '(degraded: dead leg / thin judging / no chair verdict), error, aborted.',
435
+ inputSchema: {
436
+ briefingFile: z.string().describe(
437
+ 'Path to the briefing file (self-contained material + criteria). The file is ' +
438
+ 'copied into the run dir; councils always brief via file (no inline prompt).'
439
+ ),
440
+ models: z.array(safeModel).min(2).max(10).optional().describe(
441
+ `2-10 bench seats. Short aliases (${aliasNames}) or full model IDs. Omit when using 'council'.`
442
+ ),
443
+ council: z.string().optional().describe(
444
+ "Run a saved council or built-in bench ('free', 'budget', 'frontier') instead of 'models'."
445
+ ),
446
+ chair: z.string().optional().describe(
447
+ "Chair model (default 'deepseek'). Must NOT be a bench seat; synthesizes the verdict."
448
+ ),
449
+ critic: z.string().optional().describe(
450
+ 'Optional critic seat: one bench member swaps to an adversarial brief. Must BE a bench seat. ' +
451
+ 'Mutually exclusive with lenses.'
452
+ ),
453
+ lenses: z.array(z.string()).optional().describe(
454
+ 'Optional expert lenses, one per seat (count must equal seat count). Forces no-ledger. ' +
455
+ 'Mutually exclusive with critic.'
456
+ ),
457
+ outDir: z.string().optional().describe(
458
+ 'Run directory (default <project>/council-<runId>/).'
459
+ ),
460
+ maxCost: z.number().optional().describe(
461
+ 'Whole-run USD ceiling, checked before each paid stage launch.'
462
+ ),
463
+ timeoutMinutes: z.number().optional().describe(
464
+ 'Per-leg timeout in minutes (existing fanout semantics). Default: 15.'
465
+ ),
466
+ gateway: z.enum(GATEWAY_MODES).optional().describe(
467
+ 'Routing preference: auto (default), direct, or openrouter.'
468
+ ),
469
+ project: z.string().optional().describe(
470
+ 'Optional project directory path. Auto-detected from working directory if omitted.'
471
+ ),
472
+ },
473
+ },
424
474
  {
425
475
  name: 'amicus_guide',
426
476
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },