amicus 1.2.1 → 1.3.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 +26 -0
- package/package.json +1 -1
- package/skills/second-opinion/SKILL.md +7 -0
- package/src/cli-handlers-council.js +37 -2
- package/src/cli.js +3 -0
- package/src/council/report-html.js +71 -0
- package/src/council/report.js +113 -0
- package/src/mcp-server.js +33 -2
- package/src/mcp-tools.js +56 -0
- package/src/sidecar/fanout-output.js +4 -1
- package/src/sidecar/fanout.js +8 -2
- package/src/sidecar/progress.js +16 -1
- package/src/sidecar/wave-progress.js +80 -0
- package/src/utils/pricing.js +16 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.3.0] - 2026-06-24
|
|
9
|
+
|
|
10
|
+
Making the mature council/fan-out engine legible: live per-leg progress, cost
|
|
11
|
+
surfaced in human output, the deterministic council spine reachable over MCP,
|
|
12
|
+
and a shareable verdict/disagreement report. Every change is presentation over
|
|
13
|
+
data the engine already records — no schema change.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Live per-leg fan-out progress**: a running `amicus fanout` now prints a per-leg rollup on each
|
|
17
|
+
heartbeat — every model's stage, message count, and latest action — instead of a generic "still
|
|
18
|
+
running". `amicus_status` reports per-leg `latestActivity` plus a `stalled` flag, so you can see
|
|
19
|
+
at a glance which model is working, which is quiet, and which is wedged.
|
|
20
|
+
- **Cost in human output**: the `amicus fanout` / `amicus read` human view now shows a per-leg `$`
|
|
21
|
+
cost cell and a `Wave cost:` total, and `amicus council tally` shows a run cost line. Each figure
|
|
22
|
+
is tagged by source (reported, `~` estimated, `?` unknown) so it can never be mistaken for an
|
|
23
|
+
authoritative number it isn't — surfaced straight from the existing usage telemetry.
|
|
24
|
+
- **Council over MCP**: three new MCP tools — `amicus_council_tally`, `amicus_council_stats`, and
|
|
25
|
+
`amicus_verdict` — expose the deterministic council spine (peers-only tier cascade, street-cred,
|
|
26
|
+
the reliability ledger, and verdict merge) to Claude directly, with no Bash round-trip.
|
|
27
|
+
- **`amicus council report`**: render a shareable disagreement + verdict report from a
|
|
28
|
+
`verdict.json` — the adjudication matrix (finding × judge), peers-only street-cred, findings
|
|
29
|
+
grouped by tier (Disputed first), and per-model + wave cost — as Markdown (`--md`, default) or a
|
|
30
|
+
self-contained HTML page (`--html`). Pass `--wave <wave.json>` to fold in the wave-level cost
|
|
31
|
+
total. The council skill's Stage-5 step now drives this renderer instead of hand-assembling the
|
|
32
|
+
report.
|
|
33
|
+
|
|
8
34
|
## [1.2.1] - 2026-06-24
|
|
9
35
|
|
|
10
36
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -291,6 +291,13 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
|
|
|
291
291
|
— exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
|
|
292
292
|
invent a figure. Add a wave **total cost** row from the wave document's
|
|
293
293
|
`usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`; never invent a value.
|
|
294
|
+
- **Renderer:** once `verdict.json` is written, generate the human report with
|
|
295
|
+
`amicus council report <run-folder>/verdict.json --md > <run-folder>/report.md`
|
|
296
|
+
(use `--html` for a self-contained, shareable page). This emits the
|
|
297
|
+
adjudication matrix (finding × judge), the peers-only street-cred table, the
|
|
298
|
+
findings-by-tier groupings (Disputed-first), and the per-model + wave cost —
|
|
299
|
+
deterministic data only. Prefer it over hand-assembling the matrix; reserve
|
|
300
|
+
prose for the chair's synthesis and the decision log.
|
|
294
301
|
|
|
295
302
|
Tell the user exactly which files were written and where.
|
|
296
303
|
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const { tally } = require('./council/tally');
|
|
5
5
|
const { deriveReliability } = require('./council/ledger');
|
|
6
|
+
const { sumWaveUsage, formatCost } = require('./utils/pricing');
|
|
6
7
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
8
|
+
const { buildReport } = require('./council/report');
|
|
7
9
|
|
|
8
10
|
function runTally(inputPath, useJson) {
|
|
9
11
|
if (!inputPath) {
|
|
@@ -34,8 +36,10 @@ function runStats(useJson) {
|
|
|
34
36
|
|
|
35
37
|
function renderRecord(r) {
|
|
36
38
|
const t = r.tierCounts;
|
|
39
|
+
const cost = sumWaveUsage(r.runStats || []).cost;
|
|
37
40
|
return `Council tally (${r.meta.runId})\n` +
|
|
38
|
-
` Confirmed ${t.Confirmed} Contested ${t.Contested} Singleton ${t.Singleton} Disputed ${t.Disputed}\n
|
|
41
|
+
` Confirmed ${t.Confirmed} Contested ${t.Contested} Singleton ${t.Singleton} Disputed ${t.Disputed}\n` +
|
|
42
|
+
` Cost: ${formatCost(cost)}\n`;
|
|
39
43
|
}
|
|
40
44
|
function renderStats(agg) {
|
|
41
45
|
if (!agg.length) { return 'No council runs recorded yet.\n'; }
|
|
@@ -46,14 +50,45 @@ function renderStats(agg) {
|
|
|
46
50
|
}
|
|
47
51
|
function fmt(v) { return (v === null || v === undefined) ? ' — ' : v.toFixed(2); }
|
|
48
52
|
|
|
53
|
+
function runReport(args, useJson) {
|
|
54
|
+
const verdictPath = args._[2];
|
|
55
|
+
if (!verdictPath) {
|
|
56
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council report needs a <verdict.json> path',
|
|
57
|
+
hint: 'amicus council report <verdict.json> [--wave <wave.json>] [--md|--html]' });
|
|
58
|
+
}
|
|
59
|
+
let verdict;
|
|
60
|
+
try { verdict = JSON.parse(fs.readFileSync(verdictPath, 'utf-8')); }
|
|
61
|
+
catch (e) {
|
|
62
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read ${verdictPath}: ${e.message}`,
|
|
63
|
+
hint: 'pass a valid verdict.json (from the council flow / amicus_verdict)' });
|
|
64
|
+
}
|
|
65
|
+
let wave = null;
|
|
66
|
+
if (args.wave) {
|
|
67
|
+
try { wave = JSON.parse(fs.readFileSync(args.wave, 'utf-8')); }
|
|
68
|
+
catch (e) {
|
|
69
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read --wave ${args.wave}: ${e.message}`,
|
|
70
|
+
hint: 'pass a valid wave.json or omit --wave' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let report;
|
|
74
|
+
try { report = buildReport({ verdict, wave }, { format: args.html ? 'html' : 'md' }); }
|
|
75
|
+
catch (e) {
|
|
76
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot render report: ${e.message}`,
|
|
77
|
+
hint: 'verdict.json needs findings[], streetCred[], runStats[], tierCounts' });
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(report.endsWith('\n') ? report : report + '\n');
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
/** @param {{_:string[], json?:boolean}} args @returns {Promise<number>} */
|
|
50
84
|
async function handleCouncil(args) {
|
|
51
85
|
const sub = args._[1];
|
|
52
86
|
const useJson = !!args.json;
|
|
53
87
|
if (sub === 'tally') { return runTally(args._[2], useJson); }
|
|
54
88
|
if (sub === 'stats') { return runStats(useJson); }
|
|
89
|
+
if (sub === 'report') { return runReport(args, useJson); }
|
|
55
90
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
56
|
-
message: `unknown council subcommand '${sub || ''}'`, hint: 'amicus council tally|stats' });
|
|
91
|
+
message: `unknown council subcommand '${sub || ''}'`, hint: 'amicus council tally|stats|report' });
|
|
57
92
|
}
|
|
58
93
|
|
|
59
94
|
module.exports = { handleCouncil };
|
package/src/cli.js
CHANGED
|
@@ -113,6 +113,8 @@ function isBooleanFlag(key) {
|
|
|
113
113
|
'no-validate-model',
|
|
114
114
|
'remove', // used by 'key' command only; other handlers ignore it
|
|
115
115
|
'no-cost-gate', // disable the budget gate for this run
|
|
116
|
+
'html', // council report: emit a self-contained HTML page
|
|
117
|
+
'md', // council report: emit Markdown (default)
|
|
116
118
|
];
|
|
117
119
|
return booleanFlags.includes(key);
|
|
118
120
|
}
|
|
@@ -305,6 +307,7 @@ Commands:
|
|
|
305
307
|
models List/search the model catalog, refresh it, audit aliases
|
|
306
308
|
council tally <input.json> [--json] Tally council findings → tiers/street-cred
|
|
307
309
|
council stats [--json] Reviewer-reliability from the ledger
|
|
310
|
+
council report <verdict.json> [--wave <wave.json>] [--md|--html] Disagreement+verdict report
|
|
308
311
|
doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
|
|
309
312
|
abort Abort a running session (or --all)
|
|
310
313
|
setup Configure default model and aliases
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/council/report-html.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/report-html
|
|
6
|
+
* Self-contained HTML renderer for the council report (inline CSS, no server,
|
|
7
|
+
* tier-colored rows). Consumes the neutral model from council/report.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { formatCost } = require('../utils/pricing');
|
|
11
|
+
const { TIER_ORDER, SYMBOL } = require('./report');
|
|
12
|
+
|
|
13
|
+
const TIER_COLOR = { Disputed: '#fde2e1', Contested: '#fef3c7', Confirmed: '#dcfce7', Singleton: '#e5e7eb' };
|
|
14
|
+
|
|
15
|
+
function esc(s) {
|
|
16
|
+
return String(s === null || s === undefined ? '' : s)
|
|
17
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
18
|
+
}
|
|
19
|
+
function num(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
|
|
20
|
+
function dur(ms) { return (ms === null || ms === undefined) ? '—' : `${Math.round(ms / 1000)}s`; }
|
|
21
|
+
|
|
22
|
+
function renderHtml(m) {
|
|
23
|
+
const h = m.header;
|
|
24
|
+
const judgeHead = m.judges.map(j => `<th>${esc(j)}</th>`).join('');
|
|
25
|
+
const matrixRows = m.findings.map((f) => {
|
|
26
|
+
const cells = m.judges.map((j) => {
|
|
27
|
+
const v = f.byJudge[j];
|
|
28
|
+
return `<td class="c">${v ? SYMBOL[v] : ''}${j === f.raiser ? '<sup>*</sup>' : ''}</td>`;
|
|
29
|
+
}).join('');
|
|
30
|
+
return `<tr style="background:${TIER_COLOR[f.tier] || '#fff'}">` +
|
|
31
|
+
`<td>${esc(f.id)}</td><td>${esc(f.severity)}</td><td>${esc(f.raiser)}</td>${cells}` +
|
|
32
|
+
`<td>${esc(f.tier)}</td><td>${esc(f.decision || '')}</td></tr>`;
|
|
33
|
+
}).join('');
|
|
34
|
+
const credRows = m.streetCred.map(s =>
|
|
35
|
+
`<tr><td>${esc(s.model)}</td><td>${num(s.peersOnly)}</td><td>${num(s.withSelf)}</td></tr>`).join('');
|
|
36
|
+
const tierRows = TIER_ORDER.map(t =>
|
|
37
|
+
`<tr><td>${t}</td><td>${m.tierCounts[t]}</td></tr>`).join('');
|
|
38
|
+
const costRows = m.cost.rows.map(r =>
|
|
39
|
+
`<tr><td>${esc(r.model)}</td><td>${esc(r.status)}</td><td>${dur(r.durationMs)}</td>` +
|
|
40
|
+
`<td>${esc(formatCost(r.cost))}</td></tr>`).join('');
|
|
41
|
+
const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
|
|
42
|
+
h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).map(esc).join(' · ');
|
|
43
|
+
|
|
44
|
+
return `<!DOCTYPE html>
|
|
45
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
46
|
+
<title>Council Report — ${esc(h.runId)}</title>
|
|
47
|
+
<style>
|
|
48
|
+
body { font: 14px/1.5 system-ui, sans-serif; max-width: 1000px; margin: 2rem auto; padding: 0 1rem; color: #1f2937; }
|
|
49
|
+
h1 { font-size: 1.5rem; } h2 { margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: .25rem; }
|
|
50
|
+
table { border-collapse: collapse; width: 100%; margin: .5rem 0; }
|
|
51
|
+
th, td { border: 1px solid #e5e7eb; padding: .35rem .5rem; text-align: left; }
|
|
52
|
+
th { background: #f9fafb; } td.c { text-align: center; } .meta { color: #6b7280; }
|
|
53
|
+
.legend { color: #6b7280; font-size: .85rem; }
|
|
54
|
+
</style></head><body>
|
|
55
|
+
<h1>Council Report — ${esc(h.runType)} (${esc(h.runId)})</h1>
|
|
56
|
+
<p class="meta">${meta}</p>
|
|
57
|
+
<h2>Verdict summary</h2>
|
|
58
|
+
<table><tr><th>Tier</th><th>Count</th></tr>${tierRows}</table>
|
|
59
|
+
<h2>Adjudication matrix</h2>
|
|
60
|
+
<table><tr><th>Finding</th><th>Sev</th><th>Raiser</th>${judgeHead}<th>Tier</th><th>Decision</th></tr>${matrixRows}</table>
|
|
61
|
+
<p class="legend">✓ agree · ✗ dispute · – neutral · <sup>*</sup> raiser's own vote</p>
|
|
62
|
+
<h2>Street-cred <span class="meta">(peers-only; lower = better)</span></h2>
|
|
63
|
+
<table><tr><th>Model</th><th>peers-only</th><th>with-self</th></tr>${credRows}</table>
|
|
64
|
+
<h2>Cost</h2>
|
|
65
|
+
<table><tr><th>Model</th><th>Status</th><th>Duration</th><th>Cost</th></tr>${costRows}
|
|
66
|
+
<tr><td><strong>Wave total</strong></td><td></td><td></td><td>${esc(formatCost(m.cost.total))}</td></tr></table>
|
|
67
|
+
</body></html>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { renderHtml };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// src/council/report.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/report
|
|
6
|
+
* Pure verdict/disagreement report renderer (the differentiator). Reads a
|
|
7
|
+
* verdict.json (+ optional wave.json for the cost total) and produces a single
|
|
8
|
+
* self-contained Markdown or HTML string. Renders deterministic data only — no
|
|
9
|
+
* scoring, anonymization, or synthesis (that stays in Claude).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { formatCost, sumWaveUsage } = require('../utils/pricing');
|
|
13
|
+
|
|
14
|
+
const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
|
|
15
|
+
const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
|
|
16
|
+
|
|
17
|
+
/** Build a neutral, render-agnostic model from a verdict (+ optional wave). */
|
|
18
|
+
function toModel(verdict, wave) {
|
|
19
|
+
if (!verdict || !Array.isArray(verdict.findings)) {
|
|
20
|
+
throw new Error('verdict.json must have a findings[] array');
|
|
21
|
+
}
|
|
22
|
+
const judges = verdict.council || [];
|
|
23
|
+
const findings = verdict.findings.map((f) => {
|
|
24
|
+
const byJudge = {};
|
|
25
|
+
for (const j of judges) { byJudge[j] = null; }
|
|
26
|
+
for (const adj of (f.adjudications || [])) { byJudge[adj.judge] = adj.verdict; }
|
|
27
|
+
return {
|
|
28
|
+
id: f.id, severity: f.severity, raiser: f.raiser, tier: f.tier,
|
|
29
|
+
basis: f.basis || { a: 0, d: 0, n: 0 }, decision: f.decision || null,
|
|
30
|
+
applied: f.applied === true, byJudge,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
const runStats = verdict.runStats || [];
|
|
34
|
+
const costRows = runStats.map(r => ({
|
|
35
|
+
model: r.model, status: r.status, durationMs: r.durationMs,
|
|
36
|
+
cost: r.usage && r.usage.cost ? r.usage.cost : null,
|
|
37
|
+
}));
|
|
38
|
+
const total = (wave && wave.usage && wave.usage.cost) ? wave.usage.cost : sumWaveUsage(runStats).cost;
|
|
39
|
+
return {
|
|
40
|
+
header: {
|
|
41
|
+
runType: verdict.runType || 'review', runId: verdict.runId, date: verdict.date,
|
|
42
|
+
chair: verdict.chair, council: judges, claudeInCouncil: verdict.claudeInCouncil === true,
|
|
43
|
+
},
|
|
44
|
+
tierCounts: verdict.tierCounts || { Confirmed: 0, Contested: 0, Singleton: 0, Disputed: 0 },
|
|
45
|
+
judges, findings,
|
|
46
|
+
streetCred: verdict.streetCred || [],
|
|
47
|
+
cost: { rows: costRows, total },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fmtNum(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
|
|
52
|
+
function fmtDur(ms) { return (ms === null || ms === undefined) ? '—' : `${Math.round(ms / 1000)}s`; }
|
|
53
|
+
|
|
54
|
+
function renderMd(m) {
|
|
55
|
+
const h = m.header;
|
|
56
|
+
const out = [];
|
|
57
|
+
out.push(`# Council Report — ${h.runType} (${h.runId})`);
|
|
58
|
+
const meta = [h.date, h.chair ? `chair: ${h.chair}` : null, `council: ${h.council.join(', ')}`,
|
|
59
|
+
h.claudeInCouncil ? 'Claude in council' : null].filter(Boolean).join(' · ');
|
|
60
|
+
out.push(`\n_${meta}_\n`);
|
|
61
|
+
|
|
62
|
+
out.push('## Verdict summary\n');
|
|
63
|
+
out.push('| Tier | Count |\n|---|---|');
|
|
64
|
+
for (const t of TIER_ORDER) { out.push(`| ${t} | ${m.tierCounts[t]} |`); }
|
|
65
|
+
|
|
66
|
+
out.push('\n## Adjudication matrix\n');
|
|
67
|
+
out.push(`| Finding | Sev | Raiser | ${m.judges.join(' | ')} | Tier | Decision |`);
|
|
68
|
+
out.push(`|---|---|---|${m.judges.map(() => '---').join('|')}|---|---|`);
|
|
69
|
+
for (const f of m.findings) {
|
|
70
|
+
const cells = m.judges.map((j) => {
|
|
71
|
+
const v = f.byJudge[j];
|
|
72
|
+
return (v ? SYMBOL[v] : ' ') + (j === f.raiser ? '*' : '');
|
|
73
|
+
});
|
|
74
|
+
out.push(`| ${f.id} | ${f.severity} | ${f.raiser} | ${cells.join(' | ')} | ${f.tier} | ${f.decision || ''} |`);
|
|
75
|
+
}
|
|
76
|
+
out.push('\n_Legend: ✓ agree · ✗ dispute · – neutral · `*` raiser\'s own vote_\n');
|
|
77
|
+
|
|
78
|
+
out.push('## Street-cred (peers-only; lower = better)\n');
|
|
79
|
+
out.push('| Model | peers-only | with-self |\n|---|---|---|');
|
|
80
|
+
for (const s of m.streetCred) { out.push(`| ${s.model} | ${fmtNum(s.peersOnly)} | ${fmtNum(s.withSelf)} |`); }
|
|
81
|
+
|
|
82
|
+
out.push('\n## Findings by tier\n');
|
|
83
|
+
for (const t of TIER_ORDER) {
|
|
84
|
+
const group = m.findings.filter(f => f.tier === t);
|
|
85
|
+
if (!group.length) { continue; }
|
|
86
|
+
out.push(`### ${t}`);
|
|
87
|
+
for (const f of group) {
|
|
88
|
+
const dec = f.decision ? ` — ${f.decision}${f.applied ? ' (applied)' : ''}` : '';
|
|
89
|
+
out.push(`- **${f.id}** (${f.severity}, raiser ${f.raiser}) — a${f.basis.a}/d${f.basis.d}/n${f.basis.n}${dec}`);
|
|
90
|
+
}
|
|
91
|
+
out.push('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
out.push('## Cost\n');
|
|
95
|
+
out.push('| Model | Status | Duration | Cost |\n|---|---|---|---|');
|
|
96
|
+
for (const r of m.cost.rows) { out.push(`| ${r.model} | ${r.status} | ${fmtDur(r.durationMs)} | ${formatCost(r.cost)} |`); }
|
|
97
|
+
out.push(`| **Wave total** | | | ${formatCost(m.cost.total)} |`);
|
|
98
|
+
|
|
99
|
+
return out.join('\n') + '\n';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {{verdict:object, wave?:object, tallyRecord?:object}} sources
|
|
104
|
+
* @param {{format:'md'|'html'}} opts
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function buildReport(sources, opts = {}) {
|
|
108
|
+
const model = toModel(sources.verdict, sources.wave);
|
|
109
|
+
if (opts.format === 'html') { return require('./report-html').renderHtml(model); }
|
|
110
|
+
return renderMd(model);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { buildReport, toModel, TIER_ORDER, SYMBOL };
|
package/src/mcp-server.js
CHANGED
|
@@ -8,7 +8,7 @@ const os = require('os');
|
|
|
8
8
|
const { logger } = require('./utils/logger');
|
|
9
9
|
const { safeSessionDir } = require('./utils/validators');
|
|
10
10
|
const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
|
|
11
|
-
const { readProgress } = require('./sidecar/progress');
|
|
11
|
+
const { readProgress, isStalled } = require('./sidecar/progress');
|
|
12
12
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
13
13
|
|
|
14
14
|
const sharedServer = new SharedServerManager({ logger });
|
|
@@ -264,7 +264,14 @@ const handlers = {
|
|
|
264
264
|
if (metadata.type === 'wave') {
|
|
265
265
|
const legs = (metadata.legs || []).map((legId) => {
|
|
266
266
|
const m = readMetadata(legId, cwd);
|
|
267
|
-
|
|
267
|
+
const leg = { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
|
|
268
|
+
try {
|
|
269
|
+
const p = readProgress(getSessionDir(cwd, legId));
|
|
270
|
+
leg.messages = p.messages;
|
|
271
|
+
leg.latestActivity = p.latest;
|
|
272
|
+
leg.stalled = leg.status === 'running' && isStalled(p.lastActivityMs);
|
|
273
|
+
} catch { /* no progress yet — leave base fields only */ }
|
|
274
|
+
return leg;
|
|
268
275
|
});
|
|
269
276
|
const { TERMINAL_STATUSES } = require('./utils/result-schema');
|
|
270
277
|
const done = legs.filter(l => TERMINAL_STATUSES.includes(l.status)).length;
|
|
@@ -574,6 +581,27 @@ const handlers = {
|
|
|
574
581
|
return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
|
|
575
582
|
},
|
|
576
583
|
|
|
584
|
+
async amicus_council_tally(input) {
|
|
585
|
+
try {
|
|
586
|
+
const { tally } = require('./council/tally');
|
|
587
|
+
return textResult(JSON.stringify(tally(input)));
|
|
588
|
+
} catch (err) { return textResult(`council tally failed: ${err.message}`, true); }
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
async amicus_council_stats() {
|
|
592
|
+
try {
|
|
593
|
+
const { deriveReliability } = require('./council/ledger');
|
|
594
|
+
return textResult(JSON.stringify(deriveReliability()));
|
|
595
|
+
} catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
async amicus_verdict(input) {
|
|
599
|
+
try {
|
|
600
|
+
const { buildVerdict } = require('./council/verdict');
|
|
601
|
+
return textResult(JSON.stringify(buildVerdict(input.record, input.decisions || [])));
|
|
602
|
+
} catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
|
|
603
|
+
},
|
|
604
|
+
|
|
577
605
|
async amicus_setup() {
|
|
578
606
|
try { spawnSidecarProcess(['setup']); } catch (err) {
|
|
579
607
|
return textResult(`Failed to launch setup: ${err.message}`, true);
|
|
@@ -592,6 +620,9 @@ const LEGACY_TOOL_ALIASES = {
|
|
|
592
620
|
amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
|
|
593
621
|
amicus_fanout: 'sidecar_fanout',
|
|
594
622
|
amicus_guide: 'sidecar_guide',
|
|
623
|
+
amicus_council_tally: 'sidecar_council_tally',
|
|
624
|
+
amicus_council_stats: 'sidecar_council_stats',
|
|
625
|
+
amicus_verdict: 'sidecar_verdict',
|
|
595
626
|
};
|
|
596
627
|
|
|
597
628
|
/** Start the MCP server on stdio transport */
|
package/src/mcp-tools.js
CHANGED
|
@@ -281,6 +281,62 @@ function getTools() {
|
|
|
281
281
|
),
|
|
282
282
|
},
|
|
283
283
|
},
|
|
284
|
+
{
|
|
285
|
+
name: 'amicus_council_tally',
|
|
286
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
287
|
+
description:
|
|
288
|
+
'Deterministic council tally over an ASSEMBLED, de-anonymized input ' +
|
|
289
|
+
'(meta + findings + adjudications + rankings). Peers-only tier cascade ' +
|
|
290
|
+
'(Confirmed/Contested/Disputed/Singleton) + street-cred. Pure + synchronous: ' +
|
|
291
|
+
'returns the tally record immediately. No subprocess, no polling. Claude ' +
|
|
292
|
+
'assembles the input and may override margin tiers afterward.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
meta: z.object({
|
|
295
|
+
runId: z.string(), runType: z.string().optional(), date: z.string().optional(),
|
|
296
|
+
models: z.array(z.string()).min(1), chair: z.string().optional(),
|
|
297
|
+
claudeInCouncil: z.boolean().optional(),
|
|
298
|
+
}).describe('Run metadata; meta.models lists every reviewed model.'),
|
|
299
|
+
findings: z.array(z.object({
|
|
300
|
+
id: z.string(), raiser: z.string(), severity: z.string(), claim: z.string().optional(),
|
|
301
|
+
})).describe('Run-global findings (ids already A1/B2/C3-prefixed by Claude).'),
|
|
302
|
+
adjudications: z.array(z.object({
|
|
303
|
+
judge: z.string(), findingId: z.string(), verdict: z.enum(['agree', 'dispute', 'neutral']),
|
|
304
|
+
})).describe('One row per (judge × finding).'),
|
|
305
|
+
rankings: z.array(z.object({
|
|
306
|
+
judge: z.string(), order: z.array(z.union([z.string(), z.array(z.string())])),
|
|
307
|
+
})).describe("Each judge's preference order over the reviews (ties = nested array)."),
|
|
308
|
+
runStats: z.array(z.record(z.any())).optional().describe('Optional per-model run stats (status/duration/usage).'),
|
|
309
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'amicus_council_stats',
|
|
314
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
315
|
+
description:
|
|
316
|
+
'Per-model reviewer reliability derived from the append-only council ledger ' +
|
|
317
|
+
'(avg peers-only street-cred, lifetime confirm/fact-error rates). Read-only; ' +
|
|
318
|
+
'no inputs required.',
|
|
319
|
+
inputSchema: {
|
|
320
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'amicus_verdict',
|
|
325
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
326
|
+
description:
|
|
327
|
+
"Merge a tally record with Claude's Stage-4 decisions into the verdict " +
|
|
328
|
+
'object (final tiers after overrides, decisions, applied flags). Pure + ' +
|
|
329
|
+
'synchronous; returns the verdict — does NOT write it to disk.',
|
|
330
|
+
inputSchema: {
|
|
331
|
+
record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
|
|
332
|
+
decisions: z.array(z.object({
|
|
333
|
+
id: z.string(), decision: z.string().optional(), applied: z.boolean().optional(),
|
|
334
|
+
duplicateOf: z.string().nullable().optional(),
|
|
335
|
+
tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
|
|
336
|
+
})).optional().describe('Stage-4 per-finding decisions (default []).'),
|
|
337
|
+
project: z.string().optional().describe('Optional project directory path.'),
|
|
338
|
+
},
|
|
339
|
+
},
|
|
284
340
|
{
|
|
285
341
|
name: 'amicus_guide',
|
|
286
342
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/sidecar/fanout-output.js
|
|
2
2
|
'use strict';
|
|
3
|
+
const { formatCost } = require('../utils/pricing');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @module fanout-output
|
|
@@ -36,9 +37,11 @@ function formatWaveHuman(wave) {
|
|
|
36
37
|
lines.push('─'.repeat(40));
|
|
37
38
|
const counts = wave.counts || { complete: '?', total: '?' };
|
|
38
39
|
lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
|
|
40
|
+
lines.push(` Wave cost: ${formatCost(wave.usage && wave.usage.cost)}`);
|
|
39
41
|
for (const leg of wave.legs) {
|
|
40
42
|
const label = leg.modelInput || leg.model || leg.taskId;
|
|
41
|
-
lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)}
|
|
43
|
+
lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)} ` +
|
|
44
|
+
`${String(fmtDuration(leg.durationMs)).padEnd(7)} ${formatCost(leg.usage && leg.usage.cost)}`);
|
|
42
45
|
}
|
|
43
46
|
return lines.join('\n');
|
|
44
47
|
}
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -109,7 +109,8 @@ function writeWaveMetadata(waveDir, patch) {
|
|
|
109
109
|
async function runFanout(options) {
|
|
110
110
|
const { buildWaveResult, waveExitCode } = require('../utils/result-schema');
|
|
111
111
|
const { generateTaskId, buildMcpConfig } = require('./start');
|
|
112
|
-
const { startOpenCodeServer,
|
|
112
|
+
const { startOpenCodeServer, HEARTBEAT_INTERVAL } = require('./session-utils');
|
|
113
|
+
const { createWaveHeartbeat } = require('./wave-progress');
|
|
113
114
|
const { buildContext } = require('./context-builder');
|
|
114
115
|
const { buildPrompts } = require('../prompt-builder');
|
|
115
116
|
const { installSignalAbort, markAborted } = require('../utils/session-abort');
|
|
@@ -223,7 +224,12 @@ async function runFanout(options) {
|
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
// 6. Launch all legs concurrently (runLeg never rejects)
|
|
226
|
-
const heartbeat = options.quiet
|
|
227
|
+
const heartbeat = options.quiet
|
|
228
|
+
? { stop() {} }
|
|
229
|
+
: createWaveHeartbeat(
|
|
230
|
+
legs.map((leg, i) => ({ label: leg.modelInput || leg.model, dir: legDirs[i] })),
|
|
231
|
+
HEARTBEAT_INTERVAL
|
|
232
|
+
);
|
|
227
233
|
const timeoutMs = (options.timeout || 15) * 60 * 1000;
|
|
228
234
|
const reasoning = options.thinking ? { effort: options.thinking } : undefined;
|
|
229
235
|
let legDocs;
|
package/src/sidecar/progress.js
CHANGED
|
@@ -87,6 +87,19 @@ function computeLastActivity(mtime) {
|
|
|
87
87
|
return `${diffHr}h ago`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** A leg with no new activity for longer than this (ms) is flagged stalled in rollups. */
|
|
91
|
+
const STALL_MS = 60000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Is a leg stalled? True only when we have a real idle measurement that exceeds
|
|
95
|
+
* the threshold; an unknown / just-started leg (null) is never "stalled".
|
|
96
|
+
* @param {number|null|undefined} lastActivityMs ms since last activity
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isStalled(lastActivityMs) {
|
|
100
|
+
return typeof lastActivityMs === 'number' && lastActivityMs > STALL_MS;
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/**
|
|
91
104
|
* Write a progress update to progress.json.
|
|
92
105
|
*
|
|
@@ -214,5 +227,7 @@ module.exports = {
|
|
|
214
227
|
writeProgress,
|
|
215
228
|
extractLatest,
|
|
216
229
|
computeLastActivity,
|
|
217
|
-
STAGE_LABELS
|
|
230
|
+
STAGE_LABELS,
|
|
231
|
+
STALL_MS,
|
|
232
|
+
isStalled
|
|
218
233
|
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/sidecar/wave-progress.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module wave-progress
|
|
6
|
+
* Per-leg live progress rollup for a fan-out wave. Each headless leg already
|
|
7
|
+
* writes progress.json + conversation.jsonl to its own session dir; this reads
|
|
8
|
+
* them on a timer and prints ONE terse line per leg to stderr — milestones,
|
|
9
|
+
* never a token firehose (all three council models flagged firehose noise).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { readProgress, isStalled } = require('./progress');
|
|
15
|
+
|
|
16
|
+
const WAVE_HEARTBEAT_INTERVAL = 15000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render one compact status line per leg. Pure: takes already-read leg states.
|
|
20
|
+
* @param {Array<{label:string, messages:number, latest:string, stage?:string, stalled:boolean}>} legStates
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function formatWaveProgress(legStates) {
|
|
24
|
+
return legStates.map((s) => {
|
|
25
|
+
const stage = s.stage || 'starting';
|
|
26
|
+
const flag = s.stalled ? ' ⏳stalled' : '';
|
|
27
|
+
return `[amicus] ${String(s.label).padEnd(16)} ${String(stage).padEnd(10)} ` +
|
|
28
|
+
`${s.messages} msg | ${s.latest}${flag}`;
|
|
29
|
+
}).join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a single leg's live state from its session dir. Degrades gracefully when
|
|
34
|
+
* progress.json is absent (a leg that has not started writing yet).
|
|
35
|
+
* @param {{label:string, dir:string}} leg
|
|
36
|
+
* @returns {{label:string, messages:number, latest:string, stage?:string, stalled:boolean}}
|
|
37
|
+
*/
|
|
38
|
+
function readLegState(leg) {
|
|
39
|
+
const progressPath = path.join(leg.dir, 'progress.json');
|
|
40
|
+
const convPath = path.join(leg.dir, 'conversation.jsonl');
|
|
41
|
+
|
|
42
|
+
// Degrade gracefully when neither file has been written yet
|
|
43
|
+
if (!fs.existsSync(progressPath) && !fs.existsSync(convPath)) {
|
|
44
|
+
return { label: leg.label, messages: 0, latest: 'starting…', stalled: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let p;
|
|
48
|
+
try { p = readProgress(leg.dir); } catch { p = null; }
|
|
49
|
+
if (!p) { return { label: leg.label, messages: 0, latest: 'starting…', stalled: false }; }
|
|
50
|
+
|
|
51
|
+
const state = {
|
|
52
|
+
label: leg.label,
|
|
53
|
+
messages: p.messages,
|
|
54
|
+
latest: p.latest,
|
|
55
|
+
stalled: isStalled(p.lastActivityMs),
|
|
56
|
+
};
|
|
57
|
+
if (p.stage !== undefined) {
|
|
58
|
+
state.stage = p.stage;
|
|
59
|
+
}
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start a wave heartbeat that prints a per-leg rollup each tick. Mirrors the
|
|
65
|
+
* createHeartbeat contract: returns { stop() }.
|
|
66
|
+
* @param {Array<{label:string, dir:string}>} legs
|
|
67
|
+
* @param {number} [interval]
|
|
68
|
+
* @returns {{stop: () => void}}
|
|
69
|
+
*/
|
|
70
|
+
function createWaveHeartbeat(legs, interval = WAVE_HEARTBEAT_INTERVAL) {
|
|
71
|
+
const startTime = Date.now();
|
|
72
|
+
const intervalId = setInterval(() => {
|
|
73
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
74
|
+
const states = legs.map(readLegState);
|
|
75
|
+
process.stderr.write(`[amicus] wave ${elapsed}s — ${states.length} legs\n${formatWaveProgress(states)}\n`);
|
|
76
|
+
}, interval);
|
|
77
|
+
return { stop() { clearInterval(intervalId); } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { formatWaveProgress, readLegState, createWaveHeartbeat, WAVE_HEARTBEAT_INTERVAL };
|
package/src/utils/pricing.js
CHANGED
|
@@ -90,4 +90,19 @@ function sumWaveUsage(legs) {
|
|
|
90
90
|
return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Render a resolved cost object for humans. Never invents precision: a null
|
|
95
|
+
* amount is '—' (or '?' when the source is explicitly 'unknown'); estimated /
|
|
96
|
+
* mixed costs are marked with '~' so they can't be read as authoritative.
|
|
97
|
+
* @param {{amount:number|null, source:string}|null|undefined} cost
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatCost(cost) {
|
|
101
|
+
if (!cost || cost.amount === null || cost.amount === undefined) {
|
|
102
|
+
return cost && cost.source === 'unknown' ? '?' : '—';
|
|
103
|
+
}
|
|
104
|
+
const dollars = cost.amount < 1 ? `$${cost.amount.toFixed(4)}` : `$${cost.amount.toFixed(2)}`;
|
|
105
|
+
return (cost.source === 'estimated' || cost.source === 'mixed') ? `~${dollars}` : dollars;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage, formatCost };
|