amicus 1.2.1 → 1.4.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 +38 -0
- package/README.md +12 -1
- package/electron/ipc-setup.js +18 -1
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-council.js +77 -0
- package/electron/setup-ui.js +9 -3
- package/package.json +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +6 -0
- package/skills/second-opinion/SKILL.md +23 -0
- package/src/cli-handlers-council.js +37 -2
- package/src/cli-handlers-run.js +24 -2
- package/src/cli.js +4 -0
- package/src/council/report-html.js +71 -0
- package/src/council/report.js +113 -0
- package/src/mcp-server.js +66 -6
- package/src/mcp-tools.js +61 -2
- 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/setup.js +129 -9
- package/src/sidecar/wave-progress.js +80 -0
- package/src/utils/config.js +54 -0
- package/src/utils/free-models.js +50 -0
- package/src/utils/pricing.js +16 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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,44 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.4.0] - 2026-06-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Free OpenRouter council**: a new `amicus setup` option (readline wizard + Electron Models step)
|
|
12
|
+
that stands up a zero-cost council of free `:free` OpenRouter models, saved as a first-class
|
|
13
|
+
`councils` config primitive. Run it with `amicus fanout --council free` or the `amicus_fanout` MCP
|
|
14
|
+
`council` param; the second-opinion skill reads `councils.free`. Free-model picks are detected
|
|
15
|
+
live from the catalog (the `:free` suffix is authoritative), seeded under collision-safe `free-*`
|
|
16
|
+
aliases, and a delisted member degrades gracefully (dropped with a warning) instead of failing the
|
|
17
|
+
wave. Needs only an `OPENROUTER_API_KEY`; the wizard discloses the free-tier caveats (rate limits,
|
|
18
|
+
variable quality, the OpenRouter data-sharing prerequisite). `config.default` is left untouched.
|
|
19
|
+
|
|
20
|
+
## [1.3.0] - 2026-06-24
|
|
21
|
+
|
|
22
|
+
Making the mature council/fan-out engine legible: live per-leg progress, cost
|
|
23
|
+
surfaced in human output, the deterministic council spine reachable over MCP,
|
|
24
|
+
and a shareable verdict/disagreement report. Every change is presentation over
|
|
25
|
+
data the engine already records — no schema change.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **Live per-leg fan-out progress**: a running `amicus fanout` now prints a per-leg rollup on each
|
|
29
|
+
heartbeat — every model's stage, message count, and latest action — instead of a generic "still
|
|
30
|
+
running". `amicus_status` reports per-leg `latestActivity` plus a `stalled` flag, so you can see
|
|
31
|
+
at a glance which model is working, which is quiet, and which is wedged.
|
|
32
|
+
- **Cost in human output**: the `amicus fanout` / `amicus read` human view now shows a per-leg `$`
|
|
33
|
+
cost cell and a `Wave cost:` total, and `amicus council tally` shows a run cost line. Each figure
|
|
34
|
+
is tagged by source (reported, `~` estimated, `?` unknown) so it can never be mistaken for an
|
|
35
|
+
authoritative number it isn't — surfaced straight from the existing usage telemetry.
|
|
36
|
+
- **Council over MCP**: three new MCP tools — `amicus_council_tally`, `amicus_council_stats`, and
|
|
37
|
+
`amicus_verdict` — expose the deterministic council spine (peers-only tier cascade, street-cred,
|
|
38
|
+
the reliability ledger, and verdict merge) to Claude directly, with no Bash round-trip.
|
|
39
|
+
- **`amicus council report`**: render a shareable disagreement + verdict report from a
|
|
40
|
+
`verdict.json` — the adjudication matrix (finding × judge), peers-only street-cred, findings
|
|
41
|
+
grouped by tier (Disputed first), and per-model + wave cost — as Markdown (`--md`, default) or a
|
|
42
|
+
self-contained HTML page (`--html`). Pass `--wave <wave.json>` to fold in the wave-level cost
|
|
43
|
+
total. The council skill's Stage-5 step now drives this renderer instead of hand-assembling the
|
|
44
|
+
report.
|
|
45
|
+
|
|
8
46
|
## [1.2.1] - 2026-06-24
|
|
9
47
|
|
|
10
48
|
### Fixed
|
package/README.md
CHANGED
|
@@ -152,6 +152,16 @@ Then the council waits for your confirmation.
|
|
|
152
152
|
|
|
153
153
|
The skill lives at **[`skills/second-opinion/SKILL.md`](./skills/second-opinion/SKILL.md)**; the design spec behind it is **[`skills/second-opinion/COUNCIL-DESIGN.md`](./skills/second-opinion/COUNCIL-DESIGN.md)**.
|
|
154
154
|
|
|
155
|
+
**Free council (zero-cost).** Want the cross-examination without the model spend? `amicus setup` offers a **Free OpenRouter council** mode — readline wizard option 2, and the Electron **Models** step. It detects the free `:free` models live from the catalog, lets you multi-pick (Enter takes a vendor-diverse default), and saves them as `councils.free` — a first-class `councils` config primitive seeded under collision-safe `free-*` aliases. Your `config.default` is left untouched, and all you need is an `OPENROUTER_API_KEY`.
|
|
156
|
+
|
|
157
|
+
Run it anywhere a council runs:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
amicus fanout --council free --prompt "Review this design"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The `amicus_fanout` MCP tool takes the same `council` parameter, and the `second-opinion` skill reads `councils.free` automatically. A member that gets delisted is dropped with a warning — the council still runs as long as ≥2 survive. Free models are **rate-limited and quality-variable**, and some return 404 unless you enable data-sharing at [openrouter.ai/settings/privacy](https://openrouter.ai/settings/privacy).
|
|
164
|
+
|
|
155
165
|
---
|
|
156
166
|
|
|
157
167
|
## The parallel window
|
|
@@ -246,7 +256,8 @@ amicus fanout --models gemini,deepseek,gpt --prompt "Review this design" --json
|
|
|
246
256
|
|
|
247
257
|
Fanout runs one **headless wave**: every leg gets the **same** prompt (this is the shared-prompt model the council's review stages are built on). When all legs are terminal it prints **one** JSON wave document on stdout.
|
|
248
258
|
|
|
249
|
-
- `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required).
|
|
259
|
+
- `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required, unless `--council`).
|
|
260
|
+
- `--council <name>` — run a saved council (e.g. `free`) instead of `--models`; mutually exclusive with `--models`.
|
|
250
261
|
- `--prompt <text>` / `--prompt-file <path>` — the shared briefing. `--prompt-file` avoids the ~32 KB Windows argument cap and is mutually exclusive with `--prompt`.
|
|
251
262
|
- `--wave-id <id>` — set the wave ID explicitly (leg IDs become `<id>-1..N`).
|
|
252
263
|
- `--json` — emit the wave document.
|
package/electron/ipc-setup.js
CHANGED
|
@@ -97,7 +97,8 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
97
97
|
|
|
98
98
|
// Read-modify-write: never rewrite an alias the renderer didn't send.
|
|
99
99
|
// aliasWrites values: string = set, null = delete. First run seeds live.
|
|
100
|
-
|
|
100
|
+
// councilPicks (optional): when length >= 2, seeds the free council via seedFreeCouncil.
|
|
101
|
+
ipcMain.handle('sidecar:save-config', async (_event, defaultModel, aliasWrites, councilPicks) => {
|
|
101
102
|
try {
|
|
102
103
|
const { loadConfig, saveConfig } = require('../src/utils/config');
|
|
103
104
|
let cfg = loadConfig();
|
|
@@ -119,6 +120,9 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
saveConfig(cfg);
|
|
123
|
+
if (Array.isArray(councilPicks) && councilPicks.length >= 2) {
|
|
124
|
+
require('../src/sidecar/setup').seedFreeCouncil(councilPicks);
|
|
125
|
+
}
|
|
122
126
|
return { success: true };
|
|
123
127
|
} catch (err) {
|
|
124
128
|
logger.error('save-config handler error', { error: err.message });
|
|
@@ -192,6 +196,19 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
192
196
|
return { models: [], fetchedAt: null };
|
|
193
197
|
}
|
|
194
198
|
});
|
|
199
|
+
|
|
200
|
+
// Free OpenRouter council: returns all free models from the catalog,
|
|
201
|
+
// marking those in the vendor-diverse suggested set with suggested:true.
|
|
202
|
+
ipcMain.handle('sidecar:fetch-free-models', async () => {
|
|
203
|
+
try {
|
|
204
|
+
const { getCatalog } = require('../src/utils/model-catalog');
|
|
205
|
+
const { listFreeModels, suggestFreeCouncil } = require('../src/utils/free-models');
|
|
206
|
+
const catalog = await getCatalog();
|
|
207
|
+
const free = listFreeModels(catalog);
|
|
208
|
+
const suggested = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
209
|
+
return free.map(r => ({ id: r.id, suggested: suggested.has(r.id) }));
|
|
210
|
+
} catch (_err) { return []; }
|
|
211
|
+
});
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
module.exports = { registerSetupHandlers };
|
|
@@ -22,7 +22,8 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
|
|
|
22
22
|
'sidecar:get-api-keys',
|
|
23
23
|
'sidecar:fetch-models',
|
|
24
24
|
'sidecar:get-catalog',
|
|
25
|
-
'sidecar:refresh-catalog'
|
|
25
|
+
'sidecar:refresh-catalog',
|
|
26
|
+
'sidecar:fetch-free-models'
|
|
26
27
|
];
|
|
27
28
|
if (!allowedChannels.includes(channel)) {
|
|
28
29
|
throw new Error(`IPC channel not allowed: ${channel}`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI — Free OpenRouter council picker (mounted on the Models step).
|
|
3
|
+
* Collapsible section: a checkbox list of free models fetched via IPC. Gated
|
|
4
|
+
* on the OpenRouter key (recomputed on Step-2 entry by the orchestrator).
|
|
5
|
+
* window.collectCouncilPicks() returns the checked ids for the save payload.
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
function buildCouncilSectionHTML() {
|
|
10
|
+
return `<div id="free-council-section" class="council-section">
|
|
11
|
+
<label class="council-toggle"><input type="checkbox" id="free-council-toggle">
|
|
12
|
+
<span>Set up a free OpenRouter council (zero-cost)</span></label>
|
|
13
|
+
<div id="free-council-body" style="display:none">
|
|
14
|
+
<div id="free-council-meta" class="search-meta"></div>
|
|
15
|
+
<div id="free-council-results" class="council-results"></div>
|
|
16
|
+
<div class="council-note">Free tier: rate-limited & quality-variable; some models need
|
|
17
|
+
data-sharing enabled at openrouter.ai/settings/privacy.</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCouncilScript() {
|
|
23
|
+
return `
|
|
24
|
+
(function() {
|
|
25
|
+
var toggle = document.getElementById('free-council-toggle');
|
|
26
|
+
var body = document.getElementById('free-council-body');
|
|
27
|
+
var results = document.getElementById('free-council-results');
|
|
28
|
+
var meta = document.getElementById('free-council-meta');
|
|
29
|
+
var loaded = false;
|
|
30
|
+
|
|
31
|
+
function hasOpenRouterKey() { return !!(window.configuredKeys && window.configuredKeys.openrouter); }
|
|
32
|
+
|
|
33
|
+
window.refreshCouncilGating = function() {
|
|
34
|
+
if (!toggle) { return; }
|
|
35
|
+
var ok = hasOpenRouterKey();
|
|
36
|
+
toggle.disabled = !ok;
|
|
37
|
+
if (meta && !ok) { meta.textContent = 'Add an OpenRouter API key (step 1) to enable a free council.'; }
|
|
38
|
+
else if (meta && !loaded) { meta.textContent = ''; }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function loadFree() {
|
|
42
|
+
if (loaded) { return; }
|
|
43
|
+
try {
|
|
44
|
+
var rows = await window.sidecarSetup.invoke('sidecar:fetch-free-models');
|
|
45
|
+
loaded = true;
|
|
46
|
+
results.innerHTML = '';
|
|
47
|
+
(rows || []).forEach(function(r, i) {
|
|
48
|
+
var id = 'fc-' + i;
|
|
49
|
+
var row = document.createElement('label');
|
|
50
|
+
row.className = 'council-row';
|
|
51
|
+
var cb = document.createElement('input');
|
|
52
|
+
cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
|
|
53
|
+
var span = document.createElement('span'); span.textContent = r.id;
|
|
54
|
+
row.appendChild(cb); row.appendChild(span);
|
|
55
|
+
results.appendChild(row);
|
|
56
|
+
});
|
|
57
|
+
if (meta) { meta.textContent = (rows || []).length + ' free models'; }
|
|
58
|
+
} catch (_e) { if (meta) { meta.textContent = 'Could not load free models.'; } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (toggle) {
|
|
62
|
+
toggle.addEventListener('change', function() {
|
|
63
|
+
body.style.display = toggle.checked ? '' : 'none';
|
|
64
|
+
if (toggle.checked) { loadFree(); }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
window.collectCouncilPicks = function() {
|
|
69
|
+
if (!toggle || !toggle.checked) { return []; }
|
|
70
|
+
return Array.prototype.slice.call(results.querySelectorAll('input[type=checkbox]:checked'))
|
|
71
|
+
.map(function(cb) { return cb.value; });
|
|
72
|
+
};
|
|
73
|
+
})();
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { buildCouncilSectionHTML, buildCouncilScript };
|
package/electron/setup-ui.js
CHANGED
|
@@ -5,6 +5,7 @@ const { buildAliasEditorHTML } = require('./setup-ui-aliases');
|
|
|
5
5
|
const { buildWizardCSS } = require('./setup-ui-styles');
|
|
6
6
|
const { buildKeysScript } = require('./setup-ui-keys-script');
|
|
7
7
|
const { buildAliasScript } = require('./setup-ui-alias-script');
|
|
8
|
+
const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-council');
|
|
8
9
|
const { getDefaultAliases } = require('../src/utils/config');
|
|
9
10
|
const { getBrandName } = require('./toolbar');
|
|
10
11
|
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
@@ -36,7 +37,7 @@ function buildSetupHTML(options = {}) {
|
|
|
36
37
|
<div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
|
|
37
38
|
<div class="content">
|
|
38
39
|
<div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}</div>
|
|
39
|
-
<div class="wizard-step" id="wizard-step-2">${modelHtml}</div>
|
|
40
|
+
<div class="wizard-step" id="wizard-step-2">${modelHtml}${buildCouncilSectionHTML()}</div>
|
|
40
41
|
<div class="wizard-step" id="wizard-step-3">${aliasHtml}</div>
|
|
41
42
|
<div class="wizard-step" id="wizard-step-4">
|
|
42
43
|
<div class="step-content">
|
|
@@ -57,6 +58,7 @@ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultA
|
|
|
57
58
|
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson) {
|
|
58
59
|
const keysJs = buildKeysScript();
|
|
59
60
|
const aliasJs = buildAliasScript();
|
|
61
|
+
const councilJs = buildCouncilScript();
|
|
60
62
|
return `<script>
|
|
61
63
|
window.onerror = function(msg, src, line, col, err) { console.error('WIZARD ERROR:', msg, 'at', src, line, col, err); };
|
|
62
64
|
window.onunhandledrejection = function(e) { console.error('WIZARD UNHANDLED REJECTION:', e.reason); };
|
|
@@ -91,6 +93,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
91
93
|
}
|
|
92
94
|
});
|
|
93
95
|
if (data.hints) { keyHints = data.hints; }
|
|
96
|
+
window.configuredKeys = configuredKeys;
|
|
97
|
+
window.refreshCouncilGating && window.refreshCouncilGating();
|
|
94
98
|
updateNextState();
|
|
95
99
|
if (data.imported && data.imported.length > 0) {
|
|
96
100
|
var notice = document.getElementById('import-notice');
|
|
@@ -171,7 +175,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
171
175
|
nextBtn.style.display = step < 4 ? '' : 'none';
|
|
172
176
|
finishBtn.style.display = step === 4 ? '' : 'none';
|
|
173
177
|
if (step === 4) { buildReview(); }
|
|
174
|
-
if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); }
|
|
178
|
+
if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); window.refreshCouncilGating && window.refreshCouncilGating(); }
|
|
175
179
|
if (step === 3) {
|
|
176
180
|
updateAliasRoutes();
|
|
177
181
|
if (!window.availableModels) { fetchAvailableModels(); }
|
|
@@ -383,7 +387,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
383
387
|
if (routeId) { aliasWrites[mc.alias] = routeId; }
|
|
384
388
|
}
|
|
385
389
|
}
|
|
386
|
-
await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites);
|
|
390
|
+
await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites, (window.collectCouncilPicks && window.collectCouncilPicks()) || []);
|
|
387
391
|
var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
|
|
388
392
|
await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
|
|
389
393
|
} catch (_e) { finishBtn.disabled = false; finishBtn.textContent = 'Finish'; }
|
|
@@ -512,6 +516,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
512
516
|
${aliasJs}
|
|
513
517
|
|
|
514
518
|
${keysJs}
|
|
519
|
+
|
|
520
|
+
${councilJs}
|
|
515
521
|
</script>`;
|
|
516
522
|
}
|
|
517
523
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -79,6 +79,12 @@ This section keeps only per-model **qualitative quirks** and **structural-confor
|
|
|
79
79
|
- **gpt** — thorough but verbose; peers have dinged it for volume-over-judgment. Self-ranked its own review #1 in the 2026-06-04 run → the peers-only street-cred rule (now enforced by `tally`) mitigates this. Conforms cleanly. Accessible via OpenRouter.
|
|
80
80
|
- **gemini** — fast, very large context; tends toward absolute severity labels ("blocker" inflation vs peers). Conforms cleanly; watch for preamble narration — instruct it to emit the JSON block verbatim after the prose.
|
|
81
81
|
|
|
82
|
+
## Free-tier models (OpenRouter `:free`)
|
|
83
|
+
- Heavily rate-limited (shared daily pool); a 3-leg parallel wave + cross-review can 429 mid-run.
|
|
84
|
+
- Quality-variable; weaker at strict structured (findings JSON) output.
|
|
85
|
+
- Some `:free` models 404 unless the account enables data-sharing at openrouter.ai/settings/privacy.
|
|
86
|
+
- No reliability history — chair selection can't use `council stats`; pick the strongest free model and disclose lower confidence.
|
|
87
|
+
|
|
82
88
|
## Cost guardrail
|
|
83
89
|
- The budget gate enforces this in code: a per-$/Mtok threshold (ON by default)
|
|
84
90
|
refuses o3/o3-pro-class models before a wave launches. This replaces the old
|
|
@@ -56,6 +56,22 @@ in this run is written here. Use its absolute path in all `--prompt-file` argume
|
|
|
56
56
|
|
|
57
57
|
**Pick the council.** Default: **3 models from different families (non-Claude)**. Recommend them ranked by fit, consulting both the reviewer-reliability data from `amicus council stats` (the authoritative quantitative source — runs, avg peers-only street-cred, confirm-rate, fact-error rate) and the qualitative quirks in `MODEL-NOTES.md`. State the estimated cost. The estimate is the budget gate's pre-flight figure (per-$/Mtok pricing from the cached catalog; direct-provider legs without catalog pricing are disclosed as "cost unknown"). State it as an estimate, not a guarantee. **Disclose the run shape up front** before asking for confirmation — e.g.:
|
|
58
58
|
|
|
59
|
+
**Free council (zero-cost).** If the user asks for a "free council" / "zero-cost council",
|
|
60
|
+
read `councils.free` from `~/.config/amicus/config.json` and run
|
|
61
|
+
`amicus fanout --council free --prompt-file <briefing>`. Free-tier handling:
|
|
62
|
+
- Cost ≈ $0 — skip the paid-run cost framing (the budget gate is a no-op at zero price).
|
|
63
|
+
- No reliability history: free models have no `amicus council stats` / `MODEL-NOTES` record,
|
|
64
|
+
so don't rank on street-cred. Pick the most capable free model as chair and state lower confidence.
|
|
65
|
+
- Weak structured output: small free models are less reliable at the strict findings JSON; expect
|
|
66
|
+
more `validateFindings` repair-loop hits.
|
|
67
|
+
- Throttled/truncated legs: a mid-stream 429 can yield a leg marked `complete` with a truncated,
|
|
68
|
+
unparseable review. When a free-council leg is `complete` but `validateFindings` returns
|
|
69
|
+
`NO_FENCED_BLOCK`/`NOT_PARSEABLE`, treat it as suspect/throttled — don't burn the repair loop on the
|
|
70
|
+
same throttled model; disclose it and apply the ≥2-reviews-survive wave-degrade rule.
|
|
71
|
+
- Prerequisite: free models require enabling data-sharing in OpenRouter privacy settings
|
|
72
|
+
(openrouter.ai/settings/privacy) or legs 404 at run time — catalog validation cannot catch this.
|
|
73
|
+
State this up front.
|
|
74
|
+
|
|
59
75
|
> This run uses 3 council models across 2 fanout waves + 1 chair call (~7 model runs), ~10 min.
|
|
60
76
|
|
|
61
77
|
Then **wait for confirmation**. Never launch without it. The budget gate enforces the cost guardrail in code: by default it refuses any leg whose price exceeds the per-$/Mtok threshold (the o3/o3-pro guard). To run an intentionally expensive model the user explicitly asked for by name, pass `--no-cost-gate`; to raise only the total ceiling, pass `--max-cost <$>`.
|
|
@@ -291,6 +307,13 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
|
|
|
291
307
|
— exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
|
|
292
308
|
invent a figure. Add a wave **total cost** row from the wave document's
|
|
293
309
|
`usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`; never invent a value.
|
|
310
|
+
- **Renderer:** once `verdict.json` is written, generate the human report with
|
|
311
|
+
`amicus council report <run-folder>/verdict.json --md > <run-folder>/report.md`
|
|
312
|
+
(use `--html` for a self-contained, shareable page). This emits the
|
|
313
|
+
adjudication matrix (finding × judge), the peers-only street-cred table, the
|
|
314
|
+
findings-by-tier groupings (Disputed-first), and the per-model + wave cost —
|
|
315
|
+
deterministic data only. Prefer it over hand-assembling the matrix; reserve
|
|
316
|
+
prose for the chair's synthesis and the decision log.
|
|
294
317
|
|
|
295
318
|
Tell the user exactly which files were written and where.
|
|
296
319
|
|
|
@@ -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-handlers-run.js
CHANGED
|
@@ -108,8 +108,30 @@ async function handleFanout(args) {
|
|
|
108
108
|
if (promptRes.error) {
|
|
109
109
|
process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// Council preset: expand a saved council into args.models (mutually exclusive with --models).
|
|
112
|
+
const hasModels = typeof args.models === 'string' && args.models.trim();
|
|
113
|
+
const hasCouncil = args.council !== undefined && args.council !== false;
|
|
114
|
+
if (hasModels && hasCouncil) {
|
|
115
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: pass exactly one of --models / --council, not both' }));
|
|
116
|
+
}
|
|
117
|
+
if (!hasModels && !hasCouncil) {
|
|
118
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs), or use --council <name>' }));
|
|
119
|
+
}
|
|
120
|
+
if (hasCouncil) {
|
|
121
|
+
if (typeof args.council !== 'string' || !args.council.trim()) {
|
|
122
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --council requires a council name (e.g. --council free)' }));
|
|
123
|
+
}
|
|
124
|
+
const { resolveCouncilMembers } = require('./utils/config');
|
|
125
|
+
const { readCache } = require('./utils/model-catalog');
|
|
126
|
+
const catalog = (readCache() || {}).models || [];
|
|
127
|
+
const expanded = resolveCouncilMembers(args.council.trim(), catalog);
|
|
128
|
+
if (expanded.error) {
|
|
129
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: ${expanded.error}` }));
|
|
130
|
+
}
|
|
131
|
+
if (expanded.dropped && expanded.dropped.length && !useJson) {
|
|
132
|
+
process.stderr.write(`Notice: dropped unavailable council member(s): ${expanded.dropped.join(', ')}\n`);
|
|
133
|
+
}
|
|
134
|
+
args.models = expanded.models.join(',');
|
|
113
135
|
}
|
|
114
136
|
if (args['wave-id']) {
|
|
115
137
|
const check = validateTaskId(String(args['wave-id']));
|
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
|
|
@@ -353,6 +356,7 @@ Options for 'start':
|
|
|
353
356
|
|
|
354
357
|
Options for 'fanout':
|
|
355
358
|
--models <a,b,c> Required. Comma-separated aliases or provider/model IDs
|
|
359
|
+
--council <name> Run a saved council instead of --models (e.g. free). Mutually exclusive with --models
|
|
356
360
|
--prompt <text> Task briefing (or use --prompt-file)
|
|
357
361
|
--prompt-file <path> Read the briefing from a UTF-8 file (avoids the
|
|
358
362
|
~32KB Windows argument cap). Mutually exclusive
|
|
@@ -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 };
|