amicus 3.2.3 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +106 -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 +38 -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 +203 -0
- package/src/council/run-state.js +161 -0
- package/src/council/run.js +277 -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-awareness.js +187 -0
- package/src/mcp-council-run.js +161 -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/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
package/src/council/verdict.js
CHANGED
|
@@ -2,23 +2,30 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// v4.0 §7: council family v2 — verdict docs carry {schemaVersion, type} and a
|
|
6
|
+
// nullable overallVerdict (the chair's Ship-it line; populated by the headless
|
|
7
|
+
// engine in Plan B via opts.overallVerdict, null in every Stage-4 manual path).
|
|
8
|
+
const VERDICT_SCHEMA_VERSION = 2;
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Merge a tally record with Claude's Stage-4 decisions into the verdict record.
|
|
9
12
|
* @param {object} record tally() output
|
|
10
13
|
* @param {Array<{id,decision,applied,duplicateOf,tierOverride}>} decisions
|
|
14
|
+
* @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
|
|
15
|
+
* parsed chair `VERDICT:` line; omitted/undefined → null.
|
|
11
16
|
*/
|
|
12
|
-
function buildVerdict(record, decisions = []) {
|
|
17
|
+
function buildVerdict(record, decisions = [], opts = {}) {
|
|
13
18
|
const byId = new Map(decisions.map(d => [d.id, d]));
|
|
14
19
|
return {
|
|
15
20
|
schemaVersion: VERDICT_SCHEMA_VERSION,
|
|
21
|
+
type: 'council-verdict',
|
|
16
22
|
runId: record.meta.runId,
|
|
17
23
|
runType: record.meta.runType,
|
|
18
24
|
date: record.meta.date,
|
|
19
25
|
chair: record.meta.chair,
|
|
20
26
|
council: record.meta.models,
|
|
21
27
|
claudeInCouncil: record.meta.claudeInCouncil,
|
|
28
|
+
overallVerdict: opts.overallVerdict === undefined ? null : opts.overallVerdict,
|
|
22
29
|
findings: record.findings.map(f => {
|
|
23
30
|
const d = byId.get(f.id) || {};
|
|
24
31
|
const tierOverride = d.tierOverride || f.tierOverride || null;
|
package/src/headless.js
CHANGED
|
@@ -22,7 +22,8 @@ const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require(
|
|
|
22
22
|
*
|
|
23
23
|
* #BL-7 residual (15b.3): the bare `[SIDECAR_FOLD]` string is now a LEGACY
|
|
24
24
|
* literal only, kept exported for external consumers with no nonce context
|
|
25
|
-
* (
|
|
25
|
+
* (the no-nonce fallback paths were retired in v4.0 §9 — the constant is
|
|
26
|
+
* export-only back-compat now).
|
|
26
27
|
* NOTE for anyone `.toContain('[SIDECAR_FOLD]')`-checking real run output:
|
|
27
28
|
* that substring check does NOT match the real nonced marker — a nonced
|
|
28
29
|
* marker is `[SIDECAR_FOLD:<nonce>]`, which lacks the literal closing
|
|
@@ -716,46 +717,42 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
716
717
|
* Extract summary from output (everything before the trailing fold marker)
|
|
717
718
|
* Spec Reference: §6.2 - Return summary (everything before the fold marker)
|
|
718
719
|
*
|
|
720
|
+
* v4.0 §9 (BL-7 done-done): `nonce` is REQUIRED for any non-empty output.
|
|
721
|
+
* The pre-15b.3 no-nonce fallback (matching the legacy bare `[SIDECAR_FOLD]`
|
|
722
|
+
* marker) is retired — no code path, internal or external, may complete on a
|
|
723
|
+
* bare marker. Callers with no nonce have no valid marker to split on and
|
|
724
|
+
* must not call this.
|
|
725
|
+
*
|
|
719
726
|
* @param {string} output - Raw output from OpenCode
|
|
720
|
-
* @param {string}
|
|
721
|
-
* back to matching the LEGACY bare `[SIDECAR_FOLD]` marker — this keeps
|
|
722
|
-
* extractSummary usable as a standalone string utility (e.g. re-processing
|
|
723
|
-
* output captured before the nonce scheme, or a caller that genuinely has
|
|
724
|
-
* no nonce context) without ever accepting a WRONG nonce as a match.
|
|
727
|
+
* @param {string} nonce - This run's fold nonce (required for non-empty output)
|
|
725
728
|
* @returns {string} Extracted summary
|
|
729
|
+
* @throws {TypeError} when output is non-empty and nonce is missing/empty
|
|
726
730
|
*/
|
|
727
731
|
function extractSummary(output, nonce) {
|
|
728
732
|
if (!output) {
|
|
729
733
|
return '';
|
|
730
734
|
}
|
|
735
|
+
if (!nonce) {
|
|
736
|
+
throw new TypeError('extractSummary requires a per-run nonce (15b.3/v4.0 §9)');
|
|
737
|
+
}
|
|
731
738
|
|
|
732
739
|
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
733
740
|
// A marker echoed mid-output (describing code, reproducing these
|
|
734
741
|
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
735
742
|
// content. Only the true trailing marker is stripped.
|
|
736
|
-
const idx =
|
|
743
|
+
const idx = findTrailingFoldMarker(output, nonce);
|
|
737
744
|
if (idx !== -1) {
|
|
738
745
|
return output.slice(0, idx).trim();
|
|
739
746
|
}
|
|
740
747
|
return output.trim();
|
|
741
748
|
}
|
|
742
749
|
|
|
743
|
-
/**
|
|
744
|
-
* Legacy bare-marker trailing match (`[SIDECAR_FOLD]`, no nonce) — the
|
|
745
|
-
* pre-15b.3 behavior, kept only for extractSummary's no-nonce fallback path.
|
|
746
|
-
* NEVER used by runHeadless's own detection (that always carries a nonce —
|
|
747
|
-
* see findTrailingFoldMarker), so no live completion path can be forced by a
|
|
748
|
-
* bare marker.
|
|
749
|
-
* @param {string} output
|
|
750
|
-
* @returns {number}
|
|
751
|
-
*/
|
|
752
|
-
function findLegacyBareTrailingMarker(output) {
|
|
753
|
-
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
754
|
-
return m ? m.index : -1;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
750
|
/**
|
|
758
751
|
* Format a structured fold output with metadata
|
|
752
|
+
* v4.0 §9: `nonce` is REQUIRED — the pre-15b.3 bare-`[SIDECAR_FOLD]` writer
|
|
753
|
+
* fallback is retired; the bare literal is never written by any path. The
|
|
754
|
+
* FOLD_MARKER/COMPLETE_MARKER constants remain exported for external
|
|
755
|
+
* consumers' greps/back-compat only (docs/SHIMS.md).
|
|
759
756
|
* @param {Object} options - Fold output options
|
|
760
757
|
* @param {string} options.model - Model identifier
|
|
761
758
|
* @param {string} options.sessionId - Session identifier
|
|
@@ -763,14 +760,16 @@ function findLegacyBareTrailingMarker(output) {
|
|
|
763
760
|
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
764
761
|
* @param {string} [options.mode='headless'] - Execution mode
|
|
765
762
|
* @param {string} options.summary - Summary text
|
|
766
|
-
* @param {string}
|
|
767
|
-
* falls back to the legacy bare `[SIDECAR_FOLD]` marker for back-compat with
|
|
768
|
-
* external callers of this exported utility that predate the nonce scheme.
|
|
763
|
+
* @param {string} options.nonce - This run's fold nonce (required)
|
|
769
764
|
* @returns {string} Formatted fold output
|
|
765
|
+
* @throws {TypeError} when nonce is missing/empty
|
|
770
766
|
*/
|
|
771
767
|
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary, nonce }) {
|
|
768
|
+
if (!nonce) {
|
|
769
|
+
throw new TypeError('formatFoldOutput requires a per-run nonce (15b.3/v4.0 §9)');
|
|
770
|
+
}
|
|
772
771
|
return [
|
|
773
|
-
|
|
772
|
+
buildFoldMarker(nonce),
|
|
774
773
|
`Model: ${model}`,
|
|
775
774
|
`Session: ${sessionId}`,
|
|
776
775
|
`Client: ${client || 'code-local'}`,
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
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). 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
|
+
*/
|
|
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 { isPathInside } = require('./project-root-allowlist');
|
|
19
|
+
|
|
20
|
+
function textResult(text, isError) {
|
|
21
|
+
const result = { content: [{ type: 'text', text }] };
|
|
22
|
+
if (isError) { result.isError = true; }
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the bench: models XOR council preset (amicus_fanout parity). */
|
|
27
|
+
function resolveBenchInput(input) {
|
|
28
|
+
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
29
|
+
const hasModels = inputModels.length > 0;
|
|
30
|
+
const hasCouncil = typeof input.council === 'string' && input.council.trim();
|
|
31
|
+
if (hasModels && hasCouncil) { return { error: "Pass exactly one of 'models' / 'council', not both." }; }
|
|
32
|
+
if (!hasModels && !hasCouncil) { return { error: "Provide 'models' or 'council'." }; }
|
|
33
|
+
if (hasCouncil) {
|
|
34
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
35
|
+
const { readCache } = require('./utils/model-catalog');
|
|
36
|
+
const catalog = (readCache() || {}).models || [];
|
|
37
|
+
const expanded = resolveCouncilMembers(input.council.trim(), catalog);
|
|
38
|
+
if (expanded.error) { return { error: expanded.error }; }
|
|
39
|
+
return { bench: expanded.models };
|
|
40
|
+
}
|
|
41
|
+
return { bench: inputModels };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* amicus_council_run: validate → prep run dir → spawn CLI child → return
|
|
46
|
+
* {runId, runDir} immediately (fenced).
|
|
47
|
+
* @param {object} input tool input
|
|
48
|
+
* @param {string} project resolved project dir
|
|
49
|
+
* @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
|
|
50
|
+
*/
|
|
51
|
+
async function handleCouncilRunTool(input, project, helpers) {
|
|
52
|
+
const CHAIR_DEFAULT = 'deepseek';
|
|
53
|
+
if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
|
|
54
|
+
return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
|
|
55
|
+
}
|
|
56
|
+
let briefing;
|
|
57
|
+
try { briefing = fs.readFileSync(input.briefingFile, 'utf-8'); }
|
|
58
|
+
catch (e) { return textResult(`Cannot read briefingFile ${input.briefingFile}: ${e.message}`, true); }
|
|
59
|
+
if (briefing.charCodeAt(0) === 0xFEFF) { briefing = briefing.slice(1); }
|
|
60
|
+
if (!briefing.trim()) { return textResult(`briefingFile ${input.briefingFile} is empty.`, true); }
|
|
61
|
+
|
|
62
|
+
const benchRes = resolveBenchInput(input);
|
|
63
|
+
if (benchRes.error) { return textResult(benchRes.error, true); }
|
|
64
|
+
const bench = benchRes.bench;
|
|
65
|
+
if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
|
|
66
|
+
const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
|
|
67
|
+
if (bench.includes(chair)) {
|
|
68
|
+
return textResult(`Chair '${chair}' is a bench seat — pick a chair outside the bench (default: ${CHAIR_DEFAULT}).`, true);
|
|
69
|
+
}
|
|
70
|
+
const critic = (typeof input.critic === 'string' && input.critic.trim()) ? input.critic.trim() : null;
|
|
71
|
+
if (critic && !bench.includes(critic)) {
|
|
72
|
+
return textResult(`Critic '${critic}' must be one of the bench seats (${bench.join(', ')}).`, true);
|
|
73
|
+
}
|
|
74
|
+
const lenses = Array.isArray(input.lenses) && input.lenses.length ? input.lenses : null;
|
|
75
|
+
if (critic && lenses) { return textResult('critic and lenses are mutually exclusive in v4.0.', true); }
|
|
76
|
+
if (lenses && lenses.length !== bench.length) {
|
|
77
|
+
return textResult(`lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length}).`, true);
|
|
78
|
+
}
|
|
79
|
+
if (input.timeoutMinutes !== undefined &&
|
|
80
|
+
(typeof input.timeoutMinutes !== 'number' || !Number.isFinite(input.timeoutMinutes) || input.timeoutMinutes <= 0)) {
|
|
81
|
+
return textResult('timeoutMinutes must be a positive number.', true);
|
|
82
|
+
}
|
|
83
|
+
if (input.maxCost !== undefined &&
|
|
84
|
+
(typeof input.maxCost !== 'number' || !Number.isFinite(input.maxCost) || input.maxCost <= 0)) {
|
|
85
|
+
return textResult('maxCost must be a positive number.', true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { generateTaskId } = require('./sidecar/start');
|
|
89
|
+
const runId = generateTaskId();
|
|
90
|
+
const runDir = input.outDir
|
|
91
|
+
? path.resolve(project, String(input.outDir))
|
|
92
|
+
: path.join(project, `council-${runId}`);
|
|
93
|
+
if (!isPathInside(runDir, project)) {
|
|
94
|
+
return textResult(`outDir must resolve to a path inside the project directory (${project}).`, true);
|
|
95
|
+
}
|
|
96
|
+
const briefingPath = path.join(runDir, 'briefing.md');
|
|
97
|
+
try {
|
|
98
|
+
fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
99
|
+
fs.writeFileSync(briefingPath, briefing, { mode: 0o600 });
|
|
100
|
+
runState.initRun(runDir, {
|
|
101
|
+
schemaVersion: 2, type: 'council-run', runId, status: 'running', stages: [],
|
|
102
|
+
bench, chair, critic, lenses, labelMap: null,
|
|
103
|
+
options: {
|
|
104
|
+
timeout: input.timeoutMinutes || null,
|
|
105
|
+
maxCost: (typeof input.maxCost === 'number') ? input.maxCost : null,
|
|
106
|
+
gateway: input.gateway || 'auto', outDir: runDir,
|
|
107
|
+
},
|
|
108
|
+
usage: null, createdAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
runState.writePointer(project, runId, runDir);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return textResult(`Failed to prepare council run: ${err.message}`, true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const args = [
|
|
116
|
+
'council', 'run', '--prompt-file', briefingPath, '--run-id', runId,
|
|
117
|
+
'--out-dir', runDir, '--json', '--cwd', project,
|
|
118
|
+
'--models', bench.join(','), '--chair', chair,
|
|
119
|
+
'--client', helpers.clientName,
|
|
120
|
+
];
|
|
121
|
+
if (critic) { args.push('--critic', critic); }
|
|
122
|
+
if (lenses) { args.push('--lenses', lenses.join(',')); }
|
|
123
|
+
if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
|
|
124
|
+
if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
|
|
125
|
+
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
126
|
+
|
|
127
|
+
let child;
|
|
128
|
+
try { child = 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
|
+
// Record the child's pid NOW: the engine writes its own pid at startup, but a
|
|
135
|
+
// child that dies before that leaves a pid-less status:'running' run.json that
|
|
136
|
+
// crash detection skips and abort cannot signal. Written to its own file, not
|
|
137
|
+
// patched into run.json — the child owns run.json and a cross-process
|
|
138
|
+
// read-merge-write has no lock (see run-state.writeSpawnPid).
|
|
139
|
+
try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
|
|
140
|
+
catch { /* best-effort */ }
|
|
141
|
+
|
|
142
|
+
const body = JSON.stringify({
|
|
143
|
+
schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
|
|
144
|
+
message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
|
|
145
|
+
'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
|
|
146
|
+
'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
|
|
147
|
+
});
|
|
148
|
+
// Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
|
|
149
|
+
return textResult(fenceSidecarOutput(body));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The council-awareness helpers live in their own module; re-exported here so
|
|
153
|
+
// mcp-server and cli-handlers-abort keep requiring one council MCP entry point.
|
|
154
|
+
const awareness = require('./mcp-council-awareness');
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
handleCouncilRunTool,
|
|
158
|
+
buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
|
|
159
|
+
listCouncilRuns: awareness.listCouncilRuns,
|
|
160
|
+
abortCouncilRun: awareness.abortCouncilRun,
|
|
161
|
+
};
|