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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +78 -0
- package/README.md +15 -3
- package/electron/main.js +4 -1
- package/package.json +3 -1
- package/schemas/abort.schema.json +17 -0
- package/schemas/alias-audit.schema.json +17 -0
- package/schemas/council-run.schema.json +37 -0
- package/schemas/council-stats.schema.json +28 -0
- package/schemas/council-tally.schema.json +70 -0
- package/schemas/council-validate.schema.json +22 -0
- package/schemas/council-verdict.schema.json +47 -0
- package/schemas/doctor.schema.json +29 -0
- package/schemas/error.schema.json +23 -0
- package/schemas/model-catalog.schema.json +19 -0
- package/schemas/run.schema.json +26 -0
- package/schemas/spend.schema.json +16 -0
- package/schemas/wave.schema.json +33 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
- package/skills/second-opinion/SKILL.md +8 -0
- package/src/cli-handlers-abort.js +29 -0
- package/src/cli-handlers-council-run.js +168 -0
- package/src/cli-handlers-council.js +8 -5
- package/src/cli-handlers-status.js +35 -4
- package/src/cli.js +9 -0
- package/src/council/anonymize.js +76 -0
- package/src/council/briefings-stage2.js +150 -0
- package/src/council/briefings.js +141 -0
- package/src/council/findings.js +13 -1
- package/src/council/ledger.js +13 -1
- package/src/council/parse-stage2.js +103 -0
- package/src/council/run-assemble.js +100 -0
- package/src/council/run-launch.js +99 -0
- package/src/council/run-stages.js +187 -0
- package/src/council/run-state.js +122 -0
- package/src/council/run.js +269 -0
- package/src/council/tally.js +3 -1
- package/src/council/verdict.js +9 -2
- package/src/headless.js +24 -25
- package/src/mcp-council-run.js +267 -0
- package/src/mcp-server.js +87 -28
- package/src/mcp-tools.js +50 -0
- package/src/prompt-builder.js +36 -19
- package/src/sidecar/electron-lock.js +4 -1
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/resume.js +7 -2
- package/src/utils/abort-result.js +1 -1
- package/src/utils/error-doc.js +2 -0
- package/src/utils/fold-marker.js +21 -0
- package/src/utils/route-error.js +26 -0
- package/src/utils/start-helpers.js +19 -10
- 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(
|
|
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 {
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 },
|