amicus 4.0.0 → 4.1.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 +118 -0
- package/README.md +3 -3
- package/commands/council.md +6 -6
- package/package.json +1 -1
- package/schemas/council-run.schema.json +16 -1
- package/schemas/council-tally.schema.json +10 -1
- package/schemas/council-verdict.schema.json +10 -1
- package/schemas/error.schema.json +1 -1
- package/scripts/postinstall.js +6 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +40 -0
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +266 -0
- package/skills/second-opinion/MODEL-NOTES.md +21 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +4 -0
- package/skills/second-opinion/SKILL.md +319 -333
- package/src/cli-handlers-council-run.js +9 -0
- package/src/cli-handlers-council.js +20 -2
- package/src/cli.js +8 -0
- package/src/council/briefings-debate.js +158 -0
- package/src/council/briefings-stage2.js +16 -9
- package/src/council/debate.js +98 -0
- package/src/council/ledger.js +2 -1
- package/src/council/parse-stage2.js +83 -1
- package/src/council/report-html.js +28 -1
- package/src/council/report.js +50 -2
- package/src/council/run-assemble.js +91 -9
- package/src/council/run-chair.js +145 -0
- package/src/council/run-debate.js +289 -0
- package/src/council/run-launch.js +27 -1
- package/src/council/run-stages.js +41 -13
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +108 -110
- package/src/council/verdict.js +43 -2
- package/src/mcp-council-awareness.js +187 -0
- package/src/mcp-council-run.js +29 -128
- package/src/mcp-server.js +28 -3
- package/src/mcp-tools.js +22 -2
- package/src/utils/error-doc.js +2 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// src/mcp-council-awareness.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module mcp-council-awareness
|
|
6
|
+
* The council-awareness helpers behind amicus_status / amicus_list /
|
|
7
|
+
* amicus_abort: they resolve a council runId through the sessions-dir pointer
|
|
8
|
+
* file and read the run directory directly, so the generic session handlers do
|
|
9
|
+
* not need to know anything about council run layout.
|
|
10
|
+
*
|
|
11
|
+
* Split out of mcp-council-run.js, which owns the amicus_council_run handler;
|
|
12
|
+
* that file re-exports these so existing require paths keep working.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const runState = require('./council/run-state');
|
|
18
|
+
const { RUNNING_VERSION } = require('./utils/version-info');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every wave a stage launched: the primary `waveId` plus the recorded
|
|
22
|
+
* `waveIds` sub-waves (chair ch1..ch4, lens solos, critic solo, repairs).
|
|
23
|
+
*/
|
|
24
|
+
function subWaveIds(stage) {
|
|
25
|
+
return [...new Set(
|
|
26
|
+
[stage.waveId, ...(Array.isArray(stage.waveIds) ? stage.waveIds : [])].filter(Boolean))];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The pid to probe for liveness. run.json's own pid is authoritative once the
|
|
31
|
+
* engine has checkpointed it; before that, the spawning process's record is all
|
|
32
|
+
* there is (see run-state.writeSpawnPid).
|
|
33
|
+
*/
|
|
34
|
+
function enginePid(run, runDir) {
|
|
35
|
+
return run.pid || runState.readSpawnPid(runDir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @returns {{total: number, complete: number}|null} null when not on disk yet */
|
|
39
|
+
function countWaveLegs(project, waveId) {
|
|
40
|
+
const { getSessionDir } = require('./session-manager');
|
|
41
|
+
const { TERMINAL_STATUSES } = require('./utils/result-schema');
|
|
42
|
+
let legs;
|
|
43
|
+
try {
|
|
44
|
+
legs = JSON.parse(fs.readFileSync(
|
|
45
|
+
path.join(getSessionDir(project, waveId), 'metadata.json'), 'utf-8')).legs;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
// A hand-edited or half-written metadata.json can carry a non-array `legs`;
|
|
48
|
+
// treat anything that is not an array as no legs rather than throwing out of
|
|
49
|
+
// a status read.
|
|
50
|
+
if (!Array.isArray(legs)) { return { total: 0, complete: 0 }; }
|
|
51
|
+
const complete = legs.filter((id) => {
|
|
52
|
+
try {
|
|
53
|
+
const m = JSON.parse(fs.readFileSync(
|
|
54
|
+
path.join(getSessionDir(project, id), 'metadata.json'), 'utf-8'));
|
|
55
|
+
return TERMINAL_STATUSES.includes(m.status);
|
|
56
|
+
} catch { return false; }
|
|
57
|
+
}).length;
|
|
58
|
+
return { total: legs.length, complete };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function elapsedOf(run) {
|
|
62
|
+
const end = run.completedAt || new Date().toISOString();
|
|
63
|
+
const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
|
|
64
|
+
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Status payload for a council runId, or null when the id is not a council run. */
|
|
68
|
+
function buildCouncilStatusPayload(project, taskId) {
|
|
69
|
+
const ptr = runState.readPointer(project, taskId);
|
|
70
|
+
if (!ptr) { return null; }
|
|
71
|
+
const run = runState.readRun(ptr.runDir);
|
|
72
|
+
if (!run) { return null; }
|
|
73
|
+
|
|
74
|
+
// Crash detection: a running run.json whose engine pid is gone is 'error'.
|
|
75
|
+
const pid = run.status === 'running' ? enginePid(run, ptr.runDir) : null;
|
|
76
|
+
if (pid) {
|
|
77
|
+
try { process.kill(pid, 0); } catch (err) {
|
|
78
|
+
if (err.code !== 'EPERM') {
|
|
79
|
+
runState.checkpoint(ptr.runDir, {
|
|
80
|
+
status: 'error', completedAt: new Date().toISOString(),
|
|
81
|
+
error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
|
|
82
|
+
});
|
|
83
|
+
run.status = 'error';
|
|
84
|
+
run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stages = (run.stages || []).map(s => ({
|
|
90
|
+
name: s.name, status: s.status, waveId: s.waveId || null,
|
|
91
|
+
}));
|
|
92
|
+
const active = (run.stages || []).find(s => s.status === 'running') || null;
|
|
93
|
+
let legsTotal = null; let legsComplete = null;
|
|
94
|
+
// Sum across every sub-wave the active stage launched: a lens stage1 has no
|
|
95
|
+
// seat wave at all, and a critic solo runs beside one. Stays null until at
|
|
96
|
+
// least one sub-wave record exists on disk.
|
|
97
|
+
for (const waveId of active && active.project ? subWaveIds(active) : []) {
|
|
98
|
+
const c = countWaveLegs(active.project, waveId);
|
|
99
|
+
if (!c) { continue; }
|
|
100
|
+
legsTotal = (legsTotal || 0) + c.total;
|
|
101
|
+
legsComplete = (legsComplete || 0) + c.complete;
|
|
102
|
+
}
|
|
103
|
+
const payload = {
|
|
104
|
+
taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
|
|
105
|
+
status: run.status, currentStage: active ? active.name : null, stages,
|
|
106
|
+
legsTotal, legsComplete, elapsed: elapsedOf(run),
|
|
107
|
+
exitCode: run.exitCode !== undefined ? run.exitCode : null,
|
|
108
|
+
version: RUNNING_VERSION,
|
|
109
|
+
};
|
|
110
|
+
if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** amicus_list entries for every council pointer in the project. */
|
|
115
|
+
function listCouncilRuns(project) {
|
|
116
|
+
const { sanitizePreview } = require('./sidecar/progress-fields');
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const ptr of runState.listPointers(project)) {
|
|
119
|
+
const run = runState.readRun(ptr.runDir);
|
|
120
|
+
if (!run) { continue; }
|
|
121
|
+
let briefing = '';
|
|
122
|
+
try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
|
|
123
|
+
catch { /* optional */ }
|
|
124
|
+
const active = (run.stages || []).find(s => s.status === 'running');
|
|
125
|
+
out.push({
|
|
126
|
+
id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
|
|
127
|
+
model: null, agent: 'Plan', createdAt: run.createdAt,
|
|
128
|
+
briefing: sanitizePreview(briefing, 80),
|
|
129
|
+
stage: active ? active.name : null,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Mark one sub-wave and its legs aborted. @returns {number} legs newly marked */
|
|
136
|
+
function cascadeWave(project, waveId) {
|
|
137
|
+
const { markAborted } = require('./utils/session-abort');
|
|
138
|
+
const { getSessionDir } = require('./session-manager');
|
|
139
|
+
const waveDir = getSessionDir(project, waveId);
|
|
140
|
+
let meta = {};
|
|
141
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
|
|
142
|
+
catch { /* wave record may not exist yet */ }
|
|
143
|
+
let n = 0;
|
|
144
|
+
// Non-array `legs` (hand-edited or half-written) would throw out of the
|
|
145
|
+
// for..of and lose the wave-level mark below.
|
|
146
|
+
for (const legId of Array.isArray(meta.legs) ? meta.legs : []) {
|
|
147
|
+
try { if (markAborted(getSessionDir(project, legId), 'council abort')) { n++; } }
|
|
148
|
+
catch { /* skip leg */ }
|
|
149
|
+
}
|
|
150
|
+
// Guarded so a failure here cannot discard the legs already marked: the
|
|
151
|
+
// caller only learns the count through the return value.
|
|
152
|
+
try { markAborted(waveDir, 'council abort'); } catch { /* best-effort */ }
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
|
|
158
|
+
* and cascade to every in-flight sub-wave + its legs so they settle.
|
|
159
|
+
* @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
|
|
160
|
+
*/
|
|
161
|
+
function abortCouncilRun(project, taskId) {
|
|
162
|
+
const ptr = runState.readPointer(project, taskId);
|
|
163
|
+
if (!ptr) { return null; }
|
|
164
|
+
const run = runState.readRun(ptr.runDir);
|
|
165
|
+
if (!run) { return null; }
|
|
166
|
+
if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
|
|
167
|
+
|
|
168
|
+
let cascaded = 0;
|
|
169
|
+
for (const s of run.stages || []) {
|
|
170
|
+
if (s.status !== 'running' || !s.project) { continue; }
|
|
171
|
+
for (const waveId of subWaveIds(s)) {
|
|
172
|
+
try { cascaded += cascadeWave(s.project, waveId); } catch { /* skip sub-wave */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
|
|
176
|
+
const pid = enginePid(run, ptr.runDir);
|
|
177
|
+
if (pid) {
|
|
178
|
+
try { require('./utils/abort-coordinator').waitThenKill(pid).catch(() => {}); }
|
|
179
|
+
catch { /* best-effort */ }
|
|
180
|
+
}
|
|
181
|
+
return { aborted: true, cascaded };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
subWaveIds, countWaveLegs, elapsedOf, enginePid,
|
|
186
|
+
buildCouncilStatusPayload, listCouncilRuns, cascadeWave, abortCouncilRun,
|
|
187
|
+
};
|
package/src/mcp-council-run.js
CHANGED
|
@@ -4,18 +4,17 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* @module mcp-council-run
|
|
6
6
|
* MCP surface for headless council runs (spec §8): the amicus_council_run
|
|
7
|
-
* handler (15th tool, born-fenced)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* handler (15th tool, born-fenced). Lives outside mcp-server.js
|
|
8
|
+
* (grandfathered-oversized); the spawn helper is INJECTED by mcp-server at call
|
|
9
|
+
* time to avoid a require cycle. The council-awareness helpers that
|
|
10
|
+
* amicus_status / amicus_list / amicus_abort call now live in
|
|
11
|
+
* mcp-council-awareness.js and are re-exported from here.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const runState = require('./council/run-state');
|
|
17
17
|
const { fenceSidecarOutput } = require('./utils/untrusted-fence');
|
|
18
|
-
const { RUNNING_VERSION } = require('./utils/version-info');
|
|
19
18
|
const { isPathInside } = require('./project-root-allowlist');
|
|
20
19
|
|
|
21
20
|
function textResult(text, isError) {
|
|
@@ -124,13 +123,28 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
124
123
|
if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
|
|
125
124
|
if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
|
|
126
125
|
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
// v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
|
|
127
|
+
// reason outDir is — an MCP client may send a relative path, and the child's cwd
|
|
128
|
+
// is the run dir. Validation of the file itself stays in the spawned engine's
|
|
129
|
+
// pre-flight (run-assemble.preflightClaudeReview), so every entry point shares it.
|
|
130
|
+
if (input.debate) { args.push('--debate'); }
|
|
131
|
+
if (input.claudeReviewFile) { args.push('--claude-review', path.resolve(project, String(input.claudeReviewFile))); }
|
|
132
|
+
if (input.noCostGate) { args.push('--no-cost-gate'); }
|
|
133
|
+
|
|
134
|
+
let child;
|
|
135
|
+
try { child = helpers.spawnFn(args, runDir); } catch (err) {
|
|
129
136
|
try {
|
|
130
137
|
runState.checkpoint(runDir, { status: 'error', error: { code: 'INTERNAL', message: err.message }, completedAt: new Date().toISOString() });
|
|
131
138
|
} catch { /* best-effort */ }
|
|
132
139
|
return textResult(`Failed to start council run: ${err.message}`, true);
|
|
133
140
|
}
|
|
141
|
+
// Record the child's pid NOW: the engine writes its own pid at startup, but a
|
|
142
|
+
// child that dies before that leaves a pid-less status:'running' run.json that
|
|
143
|
+
// crash detection skips and abort cannot signal. Written to its own file, not
|
|
144
|
+
// patched into run.json — the child owns run.json and a cross-process
|
|
145
|
+
// read-merge-write has no lock (see run-state.writeSpawnPid).
|
|
146
|
+
try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
|
|
147
|
+
catch { /* best-effort */ }
|
|
134
148
|
|
|
135
149
|
const body = JSON.stringify({
|
|
136
150
|
schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
|
|
@@ -142,126 +156,13 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
142
156
|
return textResult(fenceSidecarOutput(body));
|
|
143
157
|
}
|
|
144
158
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
}
|
|
159
|
+
// The council-awareness helpers live in their own module; re-exported here so
|
|
160
|
+
// mcp-server and cli-handlers-abort keep requiring one council MCP entry point.
|
|
161
|
+
const awareness = require('./mcp-council-awareness');
|
|
264
162
|
|
|
265
163
|
module.exports = {
|
|
266
|
-
handleCouncilRunTool,
|
|
164
|
+
handleCouncilRunTool,
|
|
165
|
+
buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
|
|
166
|
+
listCouncilRuns: awareness.listCouncilRuns,
|
|
167
|
+
abortCouncilRun: awareness.abortCouncilRun,
|
|
267
168
|
};
|
package/src/mcp-server.js
CHANGED
|
@@ -13,7 +13,7 @@ const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
|
|
|
13
13
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
14
14
|
const { durationBetween } = require('./utils/result-schema');
|
|
15
15
|
const { canonicalProjectPath } = require('./utils/project-path');
|
|
16
|
-
const { isAllowedProjectRoot } = require('./project-root-allowlist');
|
|
16
|
+
const { isAllowedProjectRoot, isPathInside } = require('./project-root-allowlist');
|
|
17
17
|
const { recordSession } = require('./utils/session-index');
|
|
18
18
|
const { fileURLToPath } = require('url');
|
|
19
19
|
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
@@ -1141,10 +1141,35 @@ const handlers = {
|
|
|
1141
1141
|
} catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
|
|
1142
1142
|
},
|
|
1143
1143
|
|
|
1144
|
-
async amicus_verdict(input) {
|
|
1144
|
+
async amicus_verdict(input, project) {
|
|
1145
1145
|
try {
|
|
1146
1146
|
const { buildVerdict } = require('./council/verdict');
|
|
1147
|
-
|
|
1147
|
+
// The chair's synthesis lives only in the engine's verdict.json /
|
|
1148
|
+
// chair-output.md, and this tool's output replaces verdict.json — so it
|
|
1149
|
+
// must be carried through or it is destroyed. Unlike the CLI there is no
|
|
1150
|
+
// run-folder path to anchor on (`record` arrives inline), so it is an
|
|
1151
|
+
// explicit input; omitted → null, never fabricated.
|
|
1152
|
+
const verdict = buildVerdict(input.record, input.decisions || [],
|
|
1153
|
+
{ overallVerdict: input.overallVerdict });
|
|
1154
|
+
if (!input.render) {
|
|
1155
|
+
return textResult(fenceSidecarOutput(JSON.stringify(verdict)));
|
|
1156
|
+
}
|
|
1157
|
+
// v4.1 §4.5c: return the markdown rendering (so Cowork can assemble report.md
|
|
1158
|
+
// without Bash) and, when an outDir is given, refresh report.html on disk.
|
|
1159
|
+
const { buildReport } = require('./council/report');
|
|
1160
|
+
const md = buildReport({ verdict }, { format: 'md' });
|
|
1161
|
+
if (input.outDir) {
|
|
1162
|
+
// Containment parity with amicus_council_run (mcp-council-run.js): an
|
|
1163
|
+
// MCP-supplied outDir must not write outside the project directory.
|
|
1164
|
+
const cwd = project || getProjectDir(input.project);
|
|
1165
|
+
const outDir = path.resolve(cwd, String(input.outDir));
|
|
1166
|
+
if (!isPathInside(outDir, cwd)) {
|
|
1167
|
+
return textResult(`outDir must resolve to a path inside the project directory (${cwd}).`, true);
|
|
1168
|
+
}
|
|
1169
|
+
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
1170
|
+
fs.writeFileSync(path.join(outDir, 'report.html'), buildReport({ verdict }, { format: 'html' }), { mode: 0o600 });
|
|
1171
|
+
}
|
|
1172
|
+
return textResult(fenceSidecarOutput(md));
|
|
1148
1173
|
} catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
|
|
1149
1174
|
},
|
|
1150
1175
|
|
package/src/mcp-tools.js
CHANGED
|
@@ -406,11 +406,12 @@ function getTools() {
|
|
|
406
406
|
},
|
|
407
407
|
{
|
|
408
408
|
name: 'amicus_verdict',
|
|
409
|
-
annotations: { readOnlyHint:
|
|
409
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
410
410
|
description:
|
|
411
411
|
"Merge a tally record with Claude's Stage-4 decisions into the verdict " +
|
|
412
412
|
'object (final tiers after overrides, decisions, applied flags). Pure + ' +
|
|
413
|
-
'synchronous; returns the verdict
|
|
413
|
+
'synchronous; returns the verdict. Writes nothing unless render:true AND ' +
|
|
414
|
+
'outDir are given — then it also refreshes <outDir>/report.html.',
|
|
414
415
|
inputSchema: {
|
|
415
416
|
record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
|
|
416
417
|
decisions: z.array(z.object({
|
|
@@ -418,6 +419,13 @@ function getTools() {
|
|
|
418
419
|
duplicateOf: z.string().nullable().optional(),
|
|
419
420
|
tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
|
|
420
421
|
})).optional().describe('Stage-4 per-finding decisions (default []).'),
|
|
422
|
+
overallVerdict: z.string().nullable().optional().describe(
|
|
423
|
+
"The chair's VERDICT line, read from the engine-written <runDir>/verdict.json " +
|
|
424
|
+
'(or the closing VERDICT: line of chair-output.md). Pass it through whenever you ' +
|
|
425
|
+
'overwrite verdict.json — it is the only copy, tally.json has none. Omit when the ' +
|
|
426
|
+
'chair was skipped; never author one yourself.'),
|
|
427
|
+
render: z.boolean().optional().describe('Also return the markdown rendering of the decided verdict (and refresh report.html when outDir is given).'),
|
|
428
|
+
outDir: z.string().optional().describe('Dir to write report.html into when render:true — resolved against the project dir and rejected if it escapes it. Omit to write nothing.'),
|
|
421
429
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
422
430
|
},
|
|
423
431
|
},
|
|
@@ -466,6 +474,18 @@ function getTools() {
|
|
|
466
474
|
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
467
475
|
'Routing preference: auto (default), direct, or openrouter.'
|
|
468
476
|
),
|
|
477
|
+
debate: z.boolean().optional().describe(
|
|
478
|
+
'Add a Stage-2.5 rebuttal round: raisers defend Contested/Disputed findings and ' +
|
|
479
|
+
'disputing judges re-vote before the chair synthesizes.'
|
|
480
|
+
),
|
|
481
|
+
claudeReviewFile: z.string().optional().describe(
|
|
482
|
+
"Path to Claude's own review file (prose + findings JSON) to include as a judged " +
|
|
483
|
+
'entry. Claude is reviewed and ranked like a seat, but never judges or chairs.'
|
|
484
|
+
),
|
|
485
|
+
noCostGate: z.boolean().optional().describe(
|
|
486
|
+
'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
|
|
487
|
+
'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
|
|
488
|
+
),
|
|
469
489
|
project: z.string().optional().describe(
|
|
470
490
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
471
491
|
),
|
package/src/utils/error-doc.js
CHANGED
|
@@ -22,6 +22,8 @@ const ERROR_CODES = Object.freeze({
|
|
|
22
22
|
INTERNAL: 'INTERNAL', // unexpected pre-flight throw
|
|
23
23
|
COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
|
|
24
24
|
COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
|
|
25
|
+
// council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
|
|
26
|
+
COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
/**
|