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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.2.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.2.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- return { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
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)} ${fmtDuration(leg.durationMs)}`);
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
  }
@@ -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, createHeartbeat, HEARTBEAT_INTERVAL } = require('./session-utils');
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 ? { stop() {} } : createHeartbeat(HEARTBEAT_INTERVAL);
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;
@@ -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 };
@@ -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
- module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage };
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 };