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
|
@@ -33,6 +33,13 @@ Operating lessons from each run fold back into `MODEL-NOTES.md` (with approval),
|
|
|
33
33
|
|
|
34
34
|
**Transport rule — CLI not on PATH:** every command below assumes the `amicus` CLI. If `amicus` is not on PATH (typical for **plugin-only installs**), run the identical commands as `npx -y amicus@latest <args>` (e.g. `npx -y amicus@latest fanout --models "m1,m2,m3" --prompt-file <path> --json`), or use the equivalent MCP tools (`amicus_fanout`, `amicus_start`, `amicus_wait`, `amicus_status`, `amicus_read`, `amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) — council briefings are always self-contained (`--no-context`), so MCP transport is equivalent.
|
|
35
35
|
|
|
36
|
+
**Headless contexts (v4.0):** CI and scripted environments with no Claude runtime can run the
|
|
37
|
+
whole mechanical pipeline (Stages 1–3 plus the deterministic Stage-5 artifacts) as one command —
|
|
38
|
+
`amicus council run --prompt-file <briefing.md> --models "a,b,c" --chair <model> --json` — see
|
|
39
|
+
[docs/council.md](../../docs/council.md#amicus-council-run). This skill's staged, human-in-the-loop
|
|
40
|
+
orchestration remains the interactive path: Stage 0 intake, Stage 4 decisions, and Stage 6 lessons
|
|
41
|
+
are human stages the engine never automates.
|
|
42
|
+
|
|
36
43
|
## When to use
|
|
37
44
|
|
|
38
45
|
- The user provides documents, artifacts, or links **and** an analysis request **and** criteria, and wants other models to weigh in independently.
|
|
@@ -169,6 +176,7 @@ file) returns `{waveId, taskIds[]}` immediately. Preferred: call `amicus_wait` w
|
|
|
169
176
|
one blocking call per wave; re-call it while it returns `timedOut: true`. Fallback: poll
|
|
170
177
|
`amicus_status`. Either way, `amicus_read` each leg when done. The council's briefings are always
|
|
171
178
|
self-contained (`--no-context`), so MCP transport is equivalent.
|
|
179
|
+
Council JSON returned by the MCP tools (`amicus_council_tally`, `amicus_council_stats`, `amicus_verdict`) arrives wrapped in the `<untrusted_sidecar_output>` fence since v4.0 — parse the JSON from inside the fence; CLI `--json` output remains unfenced.
|
|
172
180
|
|
|
173
181
|
**Required structured output from every model.** Instruct each council model to produce:
|
|
174
182
|
|
|
@@ -14,6 +14,31 @@ const { validateTaskId, safeSessionDir } = require('./utils/validators');
|
|
|
14
14
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
15
15
|
const { buildAbortResult } = require('./utils/result-schema');
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Council-run abort via the sessions-dir pointer (v4.0 §8). Returns the exit
|
|
19
|
+
* code when the taskId was a council run, or null to fall through to the
|
|
20
|
+
* ordinary session/wave paths.
|
|
21
|
+
*/
|
|
22
|
+
function tryCouncilAbort(project, taskId, useJson) {
|
|
23
|
+
const { abortCouncilRun } = require('./mcp-council-run');
|
|
24
|
+
const res = abortCouncilRun(project, taskId);
|
|
25
|
+
if (!res) { return null; }
|
|
26
|
+
if (res.alreadyTerminal) {
|
|
27
|
+
if (useJson) {
|
|
28
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'council-run', taskId, aborted: [] }), null, 2));
|
|
29
|
+
} else {
|
|
30
|
+
console.log(`Council run ${taskId} is not running (status: ${res.status}).`);
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (useJson) {
|
|
35
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'council-run', taskId, aborted: [taskId] }), null, 2));
|
|
36
|
+
} else {
|
|
37
|
+
console.log(`Council run ${taskId} marked as aborted (${res.cascaded} running leg(s) aborted).`);
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
/**
|
|
18
43
|
* Handle 'amicus abort --all --json': mark every running session aborted.
|
|
19
44
|
* @returns {number} exit code (always 0 — even a no-op --all is a success)
|
|
@@ -45,6 +70,8 @@ async function handleAbortTaskJson(args, taskId) {
|
|
|
45
70
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
46
71
|
|
|
47
72
|
if (!fs.existsSync(metaPath)) {
|
|
73
|
+
const council = tryCouncilAbort(project, taskId, true);
|
|
74
|
+
if (council !== null) { return council; }
|
|
48
75
|
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} not found` }));
|
|
49
76
|
}
|
|
50
77
|
|
|
@@ -171,6 +198,8 @@ async function handleAbort(args) {
|
|
|
171
198
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
172
199
|
|
|
173
200
|
if (!fs.existsSync(metaPath)) {
|
|
201
|
+
const council = tryCouncilAbort(project, taskId, false);
|
|
202
|
+
if (council !== null) { return council; }
|
|
174
203
|
console.error(`Session ${taskId} not found`);
|
|
175
204
|
process.exit(1);
|
|
176
205
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/cli-handlers-council-run.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CLI `amicus council run` (v4.0 spec §4): flag validation, headless engine
|
|
6
|
+
* invocation, document emission, exit codes. Lives in its own file because
|
|
7
|
+
* cli-handlers-council.js is near the 300-line gate; dispatched from
|
|
8
|
+
* handleCouncil. Pre-flight failures go through the error envelope (exit 1)
|
|
9
|
+
* BEFORE any spend.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { failJson, buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
|
|
14
|
+
const { validateTaskId } = require('./utils/validators');
|
|
15
|
+
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
16
|
+
|
|
17
|
+
const CHAIR_DEFAULT = 'deepseek';
|
|
18
|
+
|
|
19
|
+
function parseList(value) {
|
|
20
|
+
return String(value).split(',').map(s => s.trim()).filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve bench models from --models XOR --council (mirrors handleFanout). */
|
|
24
|
+
function resolveBench(args, useJson) {
|
|
25
|
+
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
26
|
+
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
27
|
+
if (hasModels && hasCouncil) {
|
|
28
|
+
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
29
|
+
message: 'Error: pass exactly one of --models / --council, not both' }) };
|
|
30
|
+
}
|
|
31
|
+
if (!hasModels && !hasCouncil) {
|
|
32
|
+
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
33
|
+
message: 'Error: council run needs --models a,b,c or --council <preset> (at least 2 seats)' }) };
|
|
34
|
+
}
|
|
35
|
+
if (hasCouncil) {
|
|
36
|
+
if (typeof args.council !== 'string' || !args.council.trim()) {
|
|
37
|
+
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
38
|
+
message: 'Error: --council requires a council name (e.g. --council budget)' }) };
|
|
39
|
+
}
|
|
40
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
41
|
+
const { readCache } = require('./utils/model-catalog');
|
|
42
|
+
const catalog = (readCache() || {}).models || [];
|
|
43
|
+
const expanded = resolveCouncilMembers(args.council.trim(), catalog);
|
|
44
|
+
if (expanded.error) {
|
|
45
|
+
return { fail: failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }) };
|
|
46
|
+
}
|
|
47
|
+
if (expanded.dropped && expanded.dropped.length && !useJson) {
|
|
48
|
+
process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
|
|
49
|
+
}
|
|
50
|
+
return { bench: expanded.models };
|
|
51
|
+
}
|
|
52
|
+
return { bench: parseList(args.models) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderRunHuman(run) {
|
|
56
|
+
const lines = [
|
|
57
|
+
`Council run ${run.runId}: ${run.status} (exit ${run.exitCode})`,
|
|
58
|
+
` bench: ${(run.bench || []).join(', ')} chair: ${run.chair}`,
|
|
59
|
+
` dir: ${run.options && run.options.outDir}`,
|
|
60
|
+
];
|
|
61
|
+
if (run.usage && run.usage.cost && typeof run.usage.cost.amount === 'number') {
|
|
62
|
+
lines.push(` cost: $${run.usage.cost.amount.toFixed(4)} (${run.usage.cost.source})`);
|
|
63
|
+
}
|
|
64
|
+
if (run.error) { lines.push(` error: ${run.error.code}: ${run.error.message}`); }
|
|
65
|
+
return lines.join('\n') + '\n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
|
|
69
|
+
async function handleCouncilRun(args) {
|
|
70
|
+
const useJson = !!args.json;
|
|
71
|
+
|
|
72
|
+
// --prompt-file required; inline --prompt rejected (councils always have
|
|
73
|
+
// real briefings — same rationale as MCP fanout's briefing-via-file).
|
|
74
|
+
if (args.prompt !== undefined) {
|
|
75
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
76
|
+
message: 'Error: council run takes --prompt-file only (no inline --prompt)',
|
|
77
|
+
hint: 'write the briefing to a file and pass --prompt-file <path>' });
|
|
78
|
+
}
|
|
79
|
+
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
80
|
+
const promptRes = resolvePromptSource(args);
|
|
81
|
+
if (promptRes.error) {
|
|
82
|
+
return failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const benchRes = resolveBench(args, useJson);
|
|
86
|
+
if (benchRes.fail !== undefined) { return benchRes.fail; }
|
|
87
|
+
const bench = benchRes.bench;
|
|
88
|
+
if (bench.length < 2) {
|
|
89
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
90
|
+
message: 'Error: a council needs at least 2 seats (fanout semantics)' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const chair = (typeof args.chair === 'string' && args.chair.trim())
|
|
94
|
+
? args.chair.trim() : CHAIR_DEFAULT;
|
|
95
|
+
if (bench.includes(chair)) {
|
|
96
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
97
|
+
message: `Error: chair '${chair}' is a bench seat — the chair must not review`,
|
|
98
|
+
hint: `pick a chair outside --models (default: ${CHAIR_DEFAULT}), or remove '${chair}' from the bench` });
|
|
99
|
+
}
|
|
100
|
+
const critic = (typeof args.critic === 'string' && args.critic.trim()) ? args.critic.trim() : null;
|
|
101
|
+
if (critic && !bench.includes(critic)) {
|
|
102
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
103
|
+
message: `Error: critic '${critic}' must be one of the bench seats`,
|
|
104
|
+
hint: `--critic swaps one seat's brief; pass one of: ${bench.join(', ')}` });
|
|
105
|
+
}
|
|
106
|
+
const lenses = (typeof args.lenses === 'string' && args.lenses.trim()) ? parseList(args.lenses) : null;
|
|
107
|
+
if (critic && lenses) {
|
|
108
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
109
|
+
message: 'Error: --critic and --lenses are mutually exclusive in v4.0' });
|
|
110
|
+
}
|
|
111
|
+
if (lenses && lenses.length !== bench.length) {
|
|
112
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
113
|
+
message: `Error: --lenses needs exactly one lens per seat (${bench.length} seats, got ${lenses.length})` });
|
|
114
|
+
}
|
|
115
|
+
if (args.timeout !== undefined && args.timeout <= 0) {
|
|
116
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' });
|
|
117
|
+
}
|
|
118
|
+
const mc = args['max-cost'];
|
|
119
|
+
if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
|
|
120
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' });
|
|
121
|
+
}
|
|
122
|
+
if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
|
|
123
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
124
|
+
message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` });
|
|
125
|
+
}
|
|
126
|
+
let runId;
|
|
127
|
+
if (args['run-id']) {
|
|
128
|
+
const check = validateTaskId(String(args['run-id']));
|
|
129
|
+
if (!check.valid) {
|
|
130
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error });
|
|
131
|
+
}
|
|
132
|
+
runId = String(args['run-id']);
|
|
133
|
+
} else {
|
|
134
|
+
runId = require('./sidecar/start').generateTaskId();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const project = args.cwd || process.cwd();
|
|
138
|
+
const runDir = args['out-dir']
|
|
139
|
+
? path.resolve(project, String(args['out-dir']))
|
|
140
|
+
: path.resolve(project, `council-${runId}`);
|
|
141
|
+
|
|
142
|
+
const { resolveGatewayMode } = require('./utils/config');
|
|
143
|
+
const { runCouncil } = require('./council/run');
|
|
144
|
+
const { exitCode, run } = await runCouncil({
|
|
145
|
+
briefing: promptRes.prompt, models: bench, chair, critic, lenses,
|
|
146
|
+
project, runId, runDir,
|
|
147
|
+
timeout: args.timeout, maxCost: mc !== undefined ? mc : null,
|
|
148
|
+
gateway: resolveGatewayMode(args.gateway),
|
|
149
|
+
noValidateModel: !!args['no-validate-model'],
|
|
150
|
+
date: new Date().toISOString().slice(0, 10),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (useJson) {
|
|
154
|
+
// Spec §4: exit-1 rows fail through the error envelope; 0/2 emit the manifest.
|
|
155
|
+
if (exitCode === 1 && run && run.error) {
|
|
156
|
+
process.stdout.write(JSON.stringify(buildErrorDoc({
|
|
157
|
+
code: run.error.code, message: run.error.message, command: 'council run',
|
|
158
|
+
}), null, 2) + '\n');
|
|
159
|
+
} else {
|
|
160
|
+
process.stdout.write(JSON.stringify(run, null, 2) + '\n');
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
process.stdout.write(renderRunHuman(run));
|
|
164
|
+
}
|
|
165
|
+
return exitCode;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { handleCouncilRun, CHAIR_DEFAULT };
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const { tally } = require('./council/tally');
|
|
5
|
-
const { deriveReliability, appendRun } = require('./council/ledger');
|
|
5
|
+
const { deriveReliability, appendRun, buildStatsDoc } = require('./council/ledger');
|
|
6
6
|
const { sumWaveUsage, formatCost } = require('./utils/pricing');
|
|
7
7
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
8
8
|
const { buildReport } = require('./council/report');
|
|
9
|
-
const { validateFindings } = require('./council/findings');
|
|
9
|
+
const { validateFindings, buildValidateDoc } = require('./council/findings');
|
|
10
10
|
const { buildVerdict, writeVerdictAtomic } = require('./council/verdict');
|
|
11
11
|
const {
|
|
12
12
|
runSave: runCouncilSave,
|
|
@@ -44,7 +44,9 @@ function runTally(inputPath, useJson, opts = {}) {
|
|
|
44
44
|
|
|
45
45
|
function runStats(useJson) {
|
|
46
46
|
const agg = deriveReliability();
|
|
47
|
-
|
|
47
|
+
// v4.0 §7: --json emits the enveloped doc (breaking: was a bare array);
|
|
48
|
+
// human output is unchanged (renderStats still takes the rows).
|
|
49
|
+
process.stdout.write(useJson ? JSON.stringify(buildStatsDoc(agg), null, 2) + '\n' : renderStats(agg));
|
|
48
50
|
return 0;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -117,7 +119,7 @@ function runValidate(filePath, useJson) {
|
|
|
117
119
|
hint: 'pass a Stage-1 reviewer output file (prose + trailing ```json findings block)' });
|
|
118
120
|
}
|
|
119
121
|
const result = validateFindings(text);
|
|
120
|
-
process.stdout.write(useJson ? JSON.stringify(result, null, 2) + '\n' : renderValidate(result));
|
|
122
|
+
process.stdout.write(useJson ? JSON.stringify(buildValidateDoc(result), null, 2) + '\n' : renderValidate(result));
|
|
121
123
|
return result.ok ? 0 : 2;
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -185,6 +187,7 @@ function renderVerdict(v, outPath) {
|
|
|
185
187
|
async function handleCouncil(args) {
|
|
186
188
|
const sub = args._[1];
|
|
187
189
|
const useJson = !!args.json;
|
|
190
|
+
if (sub === 'run') { return require('./cli-handlers-council-run').handleCouncilRun(args); }
|
|
188
191
|
if (sub === 'tally') { return runTally(args._[2], useJson, { append: !args['no-ledger'] }); }
|
|
189
192
|
if (sub === 'stats') { return runStats(useJson); }
|
|
190
193
|
if (sub === 'report') { return runReport(args, useJson); }
|
|
@@ -195,7 +198,7 @@ async function handleCouncil(args) {
|
|
|
195
198
|
if (sub === 'show') { return runCouncilShow(args._[2], useJson); }
|
|
196
199
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
197
200
|
message: `unknown council subcommand '${sub || ''}'`,
|
|
198
|
-
hint: 'amicus council tally|stats|report|validate|verdict|save|list|show' });
|
|
201
|
+
hint: 'amicus council run|tally|stats|report|validate|verdict|save|list|show' });
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
module.exports = { handleCouncil };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const { validateTaskId } = require('./utils/validators');
|
|
12
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
12
13
|
|
|
13
14
|
/** Render a key-value block for a single-session status payload. */
|
|
14
15
|
function formatRunHuman(d) {
|
|
@@ -42,6 +43,18 @@ function formatWaveHumanStatus(d) {
|
|
|
42
43
|
return [head, ...legLines].join('\n');
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** Render a council-run payload: header + one line per stage. */
|
|
47
|
+
function formatCouncilHuman(d) {
|
|
48
|
+
const head = `Council run ${d.runId}: ${d.status}` +
|
|
49
|
+
(d.currentStage ? ` — ${d.currentStage}` : '') +
|
|
50
|
+
(d.legsTotal !== null && d.legsTotal !== undefined ? ` (${d.legsComplete}/${d.legsTotal} legs)` : '') +
|
|
51
|
+
` (${d.elapsed})`;
|
|
52
|
+
const stageLines = (d.stages || []).map(s =>
|
|
53
|
+
` ${String(s.name).padEnd(10)} ${String(s.status).padEnd(10)}${s.waveId ? ` wave ${s.waveId}` : ''}`);
|
|
54
|
+
const tail = d.reason ? [` Reason: ${d.reason}`] : [];
|
|
55
|
+
return [head, ...stageLines, ...tail].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/**
|
|
46
59
|
* Handle 'amicus status'. Exit code 0 = status retrieved (any run state, even
|
|
47
60
|
* a failed/crashed run — the QUERY succeeded); 1 = missing/invalid/unknown id.
|
|
@@ -49,28 +62,46 @@ function formatWaveHumanStatus(d) {
|
|
|
49
62
|
* @returns {Promise<number>}
|
|
50
63
|
*/
|
|
51
64
|
async function handleStatus(args) {
|
|
65
|
+
const useJson = !!args.json;
|
|
52
66
|
const taskId = args.wave || args._[1];
|
|
53
67
|
if (!taskId || taskId === true) {
|
|
68
|
+
// v4.0 §7: --json failures land on stdout as the error doc; human stderr
|
|
69
|
+
// is byte-identical to pre-4.0.
|
|
70
|
+
if (useJson) {
|
|
71
|
+
return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: 'task_id is required for status',
|
|
72
|
+
hint: 'amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)' });
|
|
73
|
+
}
|
|
54
74
|
process.stderr.write('Error: task_id is required for status\n');
|
|
55
75
|
process.stderr.write('Usage: amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)\n');
|
|
56
76
|
return 1;
|
|
57
77
|
}
|
|
58
78
|
const check = validateTaskId(String(taskId));
|
|
59
|
-
if (!check.valid) {
|
|
79
|
+
if (!check.valid) {
|
|
80
|
+
if (useJson) { return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: check.error }); }
|
|
81
|
+
process.stderr.write(`${check.error}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
60
84
|
|
|
61
85
|
const project = args.cwd || process.cwd();
|
|
62
86
|
const { handlers } = require('./mcp-server');
|
|
63
87
|
const result = await handlers.amicus_status({ taskId: String(taskId) }, project);
|
|
64
88
|
const text = result.content[0].text;
|
|
65
|
-
if (result.isError) {
|
|
89
|
+
if (result.isError) {
|
|
90
|
+
if (useJson) { return failJson(true, { code: ERROR_CODES.BAD_SESSION, message: text }); }
|
|
91
|
+
process.stderr.write(`${text}\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
66
94
|
|
|
67
95
|
let data;
|
|
68
96
|
try { data = JSON.parse(text); } catch { process.stdout.write(`${text}\n`); return 0; }
|
|
69
97
|
delete data.next_poll; // MCP-agent polling guidance, not CLI output
|
|
70
98
|
|
|
71
99
|
if (args.json) { process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return 0; }
|
|
72
|
-
|
|
100
|
+
const rendered = data.type === 'wave' ? formatWaveHumanStatus(data)
|
|
101
|
+
: data.type === 'council-run' ? formatCouncilHuman(data)
|
|
102
|
+
: formatRunHuman(data);
|
|
103
|
+
process.stdout.write(`${rendered}\n`);
|
|
73
104
|
return 0;
|
|
74
105
|
}
|
|
75
106
|
|
|
76
|
-
module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus };
|
|
107
|
+
module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus, formatCouncilHuman };
|
package/src/cli.js
CHANGED
|
@@ -357,6 +357,7 @@ Commands:
|
|
|
357
357
|
continue New session building on previous
|
|
358
358
|
read Output session summary/conversation
|
|
359
359
|
models List/search the model catalog, refresh it, audit aliases
|
|
360
|
+
council run <briefing.md> (--models a,b,c | --council <name>) Headless council: reviews → cross-review → tally → chair → verdict
|
|
360
361
|
council tally <input.json> [--json] Tally council findings → tiers/street-cred
|
|
361
362
|
council stats [--json] Reviewer-reliability from the ledger
|
|
362
363
|
council report <verdict.json> [--wave <wave.json>] [--md|--html] Disagreement+verdict report
|
|
@@ -507,6 +508,14 @@ Subcommands for 'council':
|
|
|
507
508
|
--decisions <d.json> Optional. Stage-4 decisions array (default [])
|
|
508
509
|
-o, --out <out.json> Output path (default ./verdict.json)
|
|
509
510
|
--json Print the full verdict document
|
|
511
|
+
run --prompt-file <briefing.md> (--models a,b,c | --council <name>)
|
|
512
|
+
[--chair <model>] [--critic <model>] [--lenses s1,s2,...]
|
|
513
|
+
[--out-dir <dir>] [--json] [--max-cost <usd>] [--timeout <min>]
|
|
514
|
+
[--gateway auto|direct|openrouter] [--no-validate-model]
|
|
515
|
+
Run the full headless council engine (v4.0).
|
|
516
|
+
Chair default: deepseek (must NOT be a bench seat).
|
|
517
|
+
--critic and --lenses are mutually exclusive.
|
|
518
|
+
Exit: 0 full run, 2 degraded, 1 quorum/cost/validation.
|
|
510
519
|
save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
|
|
511
520
|
--json Machine-readable output
|
|
512
521
|
list List saved councils plus the built-in benches
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/council/anonymize.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/anonymize
|
|
6
|
+
* Pure label-map helpers for the headless council engine (spec §6).
|
|
7
|
+
* Each surviving Stage-1 review gets a stable letter label ('Review A',
|
|
8
|
+
* 'Review B', …) in bench order. The label↔model map lives ONLY in
|
|
9
|
+
* orchestrator memory and run.json — never in any judge-visible file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Assign stable labels in the given model order.
|
|
16
|
+
* @param {string[]} models reviewed model ids (bench order)
|
|
17
|
+
* @returns {{entries: Array<{label: string, letter: string, model: string}>,
|
|
18
|
+
* labelMap: Object<string, string>, letterByModel: Object<string, string>}}
|
|
19
|
+
*/
|
|
20
|
+
function assignLabels(models) {
|
|
21
|
+
if (!Array.isArray(models) || models.length === 0 || models.length > LETTERS.length) {
|
|
22
|
+
throw new Error(`assignLabels needs 1-${LETTERS.length} models`);
|
|
23
|
+
}
|
|
24
|
+
const entries = models.map((model, i) => ({
|
|
25
|
+
label: `Review ${LETTERS[i]}`, letter: LETTERS[i], model,
|
|
26
|
+
}));
|
|
27
|
+
const labelMap = {};
|
|
28
|
+
const letterByModel = {};
|
|
29
|
+
for (const e of entries) {
|
|
30
|
+
labelMap[e.label] = e.model;
|
|
31
|
+
letterByModel[e.model] = e.letter;
|
|
32
|
+
}
|
|
33
|
+
return { entries, labelMap, letterByModel };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rewrite a review's local integer finding id to its run-global label id. */
|
|
37
|
+
function toGlobalId(letter, localId) {
|
|
38
|
+
return `${letter}${localId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rewrite a validated review's findings to run-global tally-input entries.
|
|
43
|
+
* @param {string} letter review letter (e.g. 'A')
|
|
44
|
+
* @param {string} raiser de-anonymized model id
|
|
45
|
+
* @param {Array<{id: number, severity: string, claim: string, location: string}>} findings
|
|
46
|
+
* @returns {Array<{id: string, raiser: string, severity: string, claim: string, location: string}>}
|
|
47
|
+
*/
|
|
48
|
+
function toGlobalFindings(letter, raiser, findings) {
|
|
49
|
+
// claim + location must reach tally-input.json — Action v2 (Plan C) joins on
|
|
50
|
+
// them for file:line annotations; tally() reads only id/raiser/severity.
|
|
51
|
+
return findings.map(f => ({
|
|
52
|
+
id: toGlobalId(letter, f.id), raiser, severity: f.severity, claim: f.claim,
|
|
53
|
+
location: f.location,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Translate a judge's anonymized ranking (labels; ties as nested arrays) into
|
|
59
|
+
* a tally rankings[].order array of model ids via the private label map.
|
|
60
|
+
* @param {Array<string|string[]>} ranking
|
|
61
|
+
* @param {Object<string, string>} labelMap
|
|
62
|
+
* @returns {{order: Array<string|string[]>, errors: string[]}}
|
|
63
|
+
*/
|
|
64
|
+
function rankingToOrder(ranking, labelMap) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const mapOne = (label) => {
|
|
67
|
+
const model = labelMap[label];
|
|
68
|
+
if (!model) { errors.push(`unknown label '${label}'`); }
|
|
69
|
+
return model;
|
|
70
|
+
};
|
|
71
|
+
const order = (Array.isArray(ranking) ? ranking : [])
|
|
72
|
+
.map(slot => (Array.isArray(slot) ? slot.map(mapOne) : mapOne(slot)));
|
|
73
|
+
return { order, errors };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { assignLabels, toGlobalId, toGlobalFindings, rankingToOrder, LETTERS };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/council/briefings-stage2.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/briefings-stage2
|
|
6
|
+
* Pure Stage-2 (judge bundle) and Stage-3 (chair packet) template builders
|
|
7
|
+
* for the headless council engine (spec §5). Split from ./briefings.js for
|
|
8
|
+
* the 300-line gate. The bundle is identical for every judge and never leaks
|
|
9
|
+
* seat/lens/critic information or model names (skill §5.1 / Stage-2 rule).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const JUDGE_NO_TOOLS_PREAMBLE =
|
|
13
|
+
'Do NOT use any tools or read any files; everything is in this message; ' +
|
|
14
|
+
'begin immediately with A1:';
|
|
15
|
+
|
|
16
|
+
const CHAIR_NO_TOOLS_PREAMBLE =
|
|
17
|
+
'Do NOT use any tools or read any files; everything is in this message; ' +
|
|
18
|
+
'begin immediately with the verdict.';
|
|
19
|
+
|
|
20
|
+
const CHAIR_VERDICT_VALUES = ['Ship it', 'Fix these first', 'Fundamental rethink'];
|
|
21
|
+
|
|
22
|
+
/** Stage-2 headless output contract (spec §5, embedded in the judge bundle). */
|
|
23
|
+
const JUDGE_OUTPUT_CONTRACT = [
|
|
24
|
+
'End your response with a trailing fenced ```json block — no text after it — in',
|
|
25
|
+
'exactly this shape:',
|
|
26
|
+
'',
|
|
27
|
+
'```json',
|
|
28
|
+
'{',
|
|
29
|
+
' "ranking": ["Review B", "Review A", "Review C"],',
|
|
30
|
+
' "adjudications": [',
|
|
31
|
+
' { "id": "A1", "verdict": "agree" },',
|
|
32
|
+
' { "id": "B2", "verdict": "dispute" }',
|
|
33
|
+
' ]',
|
|
34
|
+
'}',
|
|
35
|
+
'```',
|
|
36
|
+
'',
|
|
37
|
+
'- "ranking": every review label below, ordered most to least accurate and insightful.',
|
|
38
|
+
' Ties: use a nested array for tied labels, e.g. [["Review A", "Review B"], "Review C"].',
|
|
39
|
+
'- "adjudications": one entry per listed finding id; "verdict" is one of:',
|
|
40
|
+
' agree | dispute | neutral. An "I missed this — it\'s valid" counts as agree.',
|
|
41
|
+
].join('\n');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The single shared anonymized judge bundle.
|
|
45
|
+
* @param {{reviews: Array<{label: string, text: string}>,
|
|
46
|
+
* findings: Array<{id: string, severity: string, claim: string}>}} args
|
|
47
|
+
*/
|
|
48
|
+
function buildJudgeBundle({ reviews, findings }) {
|
|
49
|
+
const findingLines = findings.map(f => `${f.id} [${f.severity}] ${f.claim}`).join('\n');
|
|
50
|
+
const reviewBlocks = reviews
|
|
51
|
+
.map(r => `--- ${r.label} ---\n${r.text}`)
|
|
52
|
+
.join('\n\n');
|
|
53
|
+
return [
|
|
54
|
+
JUDGE_NO_TOOLS_PREAMBLE,
|
|
55
|
+
'You are judging the anonymized peer reviews below. Do two things:',
|
|
56
|
+
'Task A — Rank: order the reviews from most to least accurate and insightful.',
|
|
57
|
+
'Task B — Adjudicate: for EVERY finding id listed below, state agree, dispute, or ' +
|
|
58
|
+
'neutral with your reasoning in prose.',
|
|
59
|
+
JUDGE_OUTPUT_CONTRACT,
|
|
60
|
+
'--- FINDINGS INDEX (run-global ids) ---',
|
|
61
|
+
findingLines,
|
|
62
|
+
reviewBlocks,
|
|
63
|
+
].join('\n\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bounded judge-repair re-prompt (solo; ≤ 2 per judge — spec §5). */
|
|
67
|
+
function buildJudgeRepairPrompt({ errors }) {
|
|
68
|
+
const lines = (errors || []).map(e => `- ${e.code}: ${e.detail}`).join('\n');
|
|
69
|
+
return [
|
|
70
|
+
'Do NOT use any tools or read any files; everything is in this message; begin ' +
|
|
71
|
+
'immediately with the JSON block.',
|
|
72
|
+
'Your previous judging response\'s trailing JSON failed validation with these errors:',
|
|
73
|
+
lines,
|
|
74
|
+
'Re-emit ONLY the corrected JSON block as a single fenced ```json block:',
|
|
75
|
+
JUDGE_OUTPUT_CONTRACT,
|
|
76
|
+
].join('\n\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Verdict-scale addendum (SEAT-BRIEFS.md § Chair verdict-scale addendum; always on headless). */
|
|
80
|
+
const VERDICT_SCALE_ADDENDUM = [
|
|
81
|
+
'After your synthesis, add two closing sections:',
|
|
82
|
+
'',
|
|
83
|
+
'1. HARD QUESTIONS — three to five questions the material\'s author has probably not',
|
|
84
|
+
' asked themselves, chosen so that an unanswerable question reveals a structural gap',
|
|
85
|
+
' (not gotchas — questions whose answers should exist).',
|
|
86
|
+
'2. A final line, alone on the last line, containing ONLY the phrase — no rationale, no',
|
|
87
|
+
' dash, no trailing text of any kind — exactly one of:',
|
|
88
|
+
'',
|
|
89
|
+
' VERDICT: Ship it',
|
|
90
|
+
' VERDICT: Fix these first',
|
|
91
|
+
' VERDICT: Fundamental rethink',
|
|
92
|
+
'',
|
|
93
|
+
' Pick one. "Ship it" = solid, nothing blocking. "Fix these first" = specific gaps',
|
|
94
|
+
' must be resolved first. "Fundamental rethink" = structural problems that cannot be',
|
|
95
|
+
' patched. Name the gaps or the structural problems in the synthesis ABOVE, not on the',
|
|
96
|
+
' VERDICT line itself — that line carries the phrase and nothing else.',
|
|
97
|
+
].join('\n');
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* De-anonymized chair packet (spec §5/§6: the chair sees identities).
|
|
101
|
+
* @param {{reviews: Array<{model: string, text: string}>,
|
|
102
|
+
* rankings: Array<{judge: string, order: Array<string|string[]>}>,
|
|
103
|
+
* adjudications: Array<{findingId: string, judge: string, verdict: string}>,
|
|
104
|
+
* tierCounts: object}} args
|
|
105
|
+
*/
|
|
106
|
+
function buildChairPacket({ reviews, rankings, adjudications, tierCounts }) {
|
|
107
|
+
const reviewBlocks = reviews.map(r => `--- Review by ${r.model} ---\n${r.text}`).join('\n\n');
|
|
108
|
+
const rankingLines = rankings
|
|
109
|
+
.map(r => `${r.judge}: ${JSON.stringify(r.order)}`)
|
|
110
|
+
.join('\n');
|
|
111
|
+
const adjLines = adjudications
|
|
112
|
+
.map(a => `${a.findingId} — ${a.judge}: ${a.verdict}`)
|
|
113
|
+
.join('\n');
|
|
114
|
+
const tiers = JSON.stringify(tierCounts);
|
|
115
|
+
return [
|
|
116
|
+
CHAIR_NO_TOOLS_PREAMBLE,
|
|
117
|
+
'You are the council chair. Write the synthesized verdict across the reviews, ' +
|
|
118
|
+
'rankings, and adjudications below. Weigh each reviewer\'s findings by their ' +
|
|
119
|
+
'peer-validated standing (rank position and adjudication pattern), distinguish ' +
|
|
120
|
+
'findings the bench broadly endorsed from contested or singleton claims, and ' +
|
|
121
|
+
'arrive at an overall assessment of the material.',
|
|
122
|
+
`Deterministic tier counts (peers-only cascade): ${tiers}`,
|
|
123
|
+
'--- STAGE-1 REVIEWS (de-anonymized) ---',
|
|
124
|
+
reviewBlocks,
|
|
125
|
+
'--- PEER RANKINGS (judge: order, best first) ---',
|
|
126
|
+
rankingLines,
|
|
127
|
+
'--- PER-FINDING ADJUDICATIONS ---',
|
|
128
|
+
adjLines,
|
|
129
|
+
VERDICT_SCALE_ADDENDUM,
|
|
130
|
+
].join('\n\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One-shot chair repair: the VERDICT line was missing (spec §5 chair contract). */
|
|
134
|
+
function buildChairRepairPrompt() {
|
|
135
|
+
return [
|
|
136
|
+
'Do NOT use any tools or read any files; everything is in this message; begin ' +
|
|
137
|
+
'immediately with the VERDICT line.',
|
|
138
|
+
'Your synthesis was received, but the final parseable line was missing. Emit ONLY ' +
|
|
139
|
+
'one line, exactly one of:',
|
|
140
|
+
'VERDICT: Ship it',
|
|
141
|
+
'VERDICT: Fix these first',
|
|
142
|
+
'VERDICT: Fundamental rethink',
|
|
143
|
+
].join('\n\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
JUDGE_NO_TOOLS_PREAMBLE, CHAIR_NO_TOOLS_PREAMBLE, CHAIR_VERDICT_VALUES,
|
|
148
|
+
JUDGE_OUTPUT_CONTRACT, VERDICT_SCALE_ADDENDUM,
|
|
149
|
+
buildJudgeBundle, buildJudgeRepairPrompt, buildChairPacket, buildChairRepairPrompt,
|
|
150
|
+
};
|