amicus 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +149 -0
- package/README.md +40 -170
- package/bin/amicus.js +14 -20
- package/commands/council.md +3 -1
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +24 -23
- package/skills/sidecar/SKILL.md +3 -3
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-run.js +4 -4
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli.js +35 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +132 -108
- package/src/mcp-tools.js +27 -3
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +12 -5
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +19 -4
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +29 -5
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
|
@@ -6,6 +6,13 @@ const { deriveReliability, appendRun } = require('./council/ledger');
|
|
|
6
6
|
const { sumWaveUsage, formatCost } = require('./utils/pricing');
|
|
7
7
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
8
8
|
const { buildReport } = require('./council/report');
|
|
9
|
+
const { validateFindings } = require('./council/findings');
|
|
10
|
+
const { buildVerdict, writeVerdictAtomic } = require('./council/verdict');
|
|
11
|
+
const {
|
|
12
|
+
runSave: runCouncilSave,
|
|
13
|
+
runList: runCouncilList,
|
|
14
|
+
runShow: runCouncilShow,
|
|
15
|
+
} = require('./council/presets-cli');
|
|
9
16
|
|
|
10
17
|
function runTally(inputPath, useJson, opts = {}) {
|
|
11
18
|
if (!inputPath) {
|
|
@@ -87,6 +94,93 @@ function runReport(args, useJson) {
|
|
|
87
94
|
return 0;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/**
|
|
98
|
+
* `amicus council validate <file>` — thin wrapper over `validateFindings`
|
|
99
|
+
* (src/council/findings.js). Tri-state outcome, distinct from the usual
|
|
100
|
+
* two-state (0/1) CLI convention:
|
|
101
|
+
* exit 0 ok:true — findings block is well-formed
|
|
102
|
+
* exit 2 ok:false — findings block parsed as a *result*, but
|
|
103
|
+
* validation failed (a distinct, scriptable
|
|
104
|
+
* outcome — mirrors the repo's exit-2
|
|
105
|
+
* "completed-with-failure" convention)
|
|
106
|
+
* exit 1 BAD_ARGS envelope — missing/unreadable input file
|
|
107
|
+
*/
|
|
108
|
+
function runValidate(filePath, useJson) {
|
|
109
|
+
if (!filePath) {
|
|
110
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council validate needs a <file> path',
|
|
111
|
+
hint: 'amicus council validate <file> [--json]' });
|
|
112
|
+
}
|
|
113
|
+
let text;
|
|
114
|
+
try { text = fs.readFileSync(filePath, 'utf-8'); }
|
|
115
|
+
catch (e) {
|
|
116
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read ${filePath}: ${e.message}`,
|
|
117
|
+
hint: 'pass a Stage-1 reviewer output file (prose + trailing ```json findings block)' });
|
|
118
|
+
}
|
|
119
|
+
const result = validateFindings(text);
|
|
120
|
+
process.stdout.write(useJson ? JSON.stringify(result, null, 2) + '\n' : renderValidate(result));
|
|
121
|
+
return result.ok ? 0 : 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderValidate(result) {
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
const hist = {};
|
|
127
|
+
for (const f of result.findings) { hist[f.severity] = (hist[f.severity] || 0) + 1; }
|
|
128
|
+
const parts = Object.keys(hist).map(sev => `${sev} ${hist[sev]}`).join(', ');
|
|
129
|
+
const n = result.findings.length;
|
|
130
|
+
return `OK — ${n} finding${n === 1 ? '' : 's'}${parts ? ` (${parts})` : ''}\n`;
|
|
131
|
+
}
|
|
132
|
+
return 'INVALID\n' + result.errors.map(e => ` ${e.code}: ${e.detail}`).join('\n') + '\n';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `amicus council verdict <tally.json> --decisions <decisions.json> [-o|--out <out.json>]`
|
|
137
|
+
* Thin wrapper over `buildVerdict` + `writeVerdictAtomic` (src/council/verdict.js).
|
|
138
|
+
* `--decisions` is optional (buildVerdict defaults decisions to []). Writes to
|
|
139
|
+
* `-o`/`--out` (default `./verdict.json`) via the atomic tmp+rename convention.
|
|
140
|
+
*/
|
|
141
|
+
function runVerdict(args, useJson) {
|
|
142
|
+
const tallyPath = args._[2];
|
|
143
|
+
if (!tallyPath) {
|
|
144
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council verdict needs a <tally.json> path',
|
|
145
|
+
hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
|
|
146
|
+
}
|
|
147
|
+
let record;
|
|
148
|
+
try { record = JSON.parse(fs.readFileSync(tallyPath, 'utf-8')); }
|
|
149
|
+
catch (e) {
|
|
150
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read ${tallyPath}: ${e.message}`,
|
|
151
|
+
hint: 'pass a valid tally.json (from `amicus council tally` / amicus_council_tally)' });
|
|
152
|
+
}
|
|
153
|
+
let decisions = [];
|
|
154
|
+
const decisionsPath = args.decisions;
|
|
155
|
+
if (decisionsPath) {
|
|
156
|
+
try { decisions = JSON.parse(fs.readFileSync(decisionsPath, 'utf-8')); }
|
|
157
|
+
catch (e) {
|
|
158
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot read --decisions ${decisionsPath}: ${e.message}`,
|
|
159
|
+
hint: 'pass a valid decisions.json array or omit --decisions' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const outPath = args.out || './verdict.json';
|
|
163
|
+
let verdict;
|
|
164
|
+
try { verdict = buildVerdict(record, decisions); }
|
|
165
|
+
catch (e) {
|
|
166
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `cannot build verdict: ${e.message}`,
|
|
167
|
+
hint: 'either tally.json needs meta, findings[], streetCred[], runStats, tierCounts, or decisions.json must be a JSON array of {id, decision, …} objects' });
|
|
168
|
+
}
|
|
169
|
+
writeVerdictAtomic(outPath, verdict);
|
|
170
|
+
process.stdout.write(useJson ? JSON.stringify(verdict, null, 2) + '\n' : renderVerdict(verdict, outPath));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderVerdict(v, outPath) {
|
|
175
|
+
const counts = {};
|
|
176
|
+
for (const f of v.findings) {
|
|
177
|
+
const key = f.decision || 'undecided';
|
|
178
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
179
|
+
}
|
|
180
|
+
const parts = Object.keys(counts).map(k => `${k} ${counts[k]}`).join(' ');
|
|
181
|
+
return `Verdict (schema v${v.schemaVersion}, ${v.runId}) → ${outPath}\n ${parts}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
90
184
|
/** @param {{_:string[], json?:boolean}} args @returns {Promise<number>} */
|
|
91
185
|
async function handleCouncil(args) {
|
|
92
186
|
const sub = args._[1];
|
|
@@ -94,8 +188,14 @@ async function handleCouncil(args) {
|
|
|
94
188
|
if (sub === 'tally') { return runTally(args._[2], useJson, { append: !args['no-ledger'] }); }
|
|
95
189
|
if (sub === 'stats') { return runStats(useJson); }
|
|
96
190
|
if (sub === 'report') { return runReport(args, useJson); }
|
|
191
|
+
if (sub === 'validate') { return runValidate(args._[2], useJson); }
|
|
192
|
+
if (sub === 'verdict') { return runVerdict(args, useJson); }
|
|
193
|
+
if (sub === 'save') { return runCouncilSave(args._[2], args.models, useJson); }
|
|
194
|
+
if (sub === 'list') { return runCouncilList(useJson); }
|
|
195
|
+
if (sub === 'show') { return runCouncilShow(args._[2], useJson); }
|
|
97
196
|
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
98
|
-
message: `unknown council subcommand '${sub || ''}'`,
|
|
197
|
+
message: `unknown council subcommand '${sub || ''}'`,
|
|
198
|
+
hint: 'amicus council tally|stats|report|validate|verdict|save|list|show' });
|
|
99
199
|
}
|
|
100
200
|
|
|
101
201
|
module.exports = { handleCouncil };
|
|
@@ -48,8 +48,13 @@ function realDeps() {
|
|
|
48
48
|
return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
|
|
49
49
|
&& fs.existsSync(path.join(dir, 'second-opinion', 'SKILL.md'));
|
|
50
50
|
},
|
|
51
|
+
now: () => Date.now(),
|
|
52
|
+
listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
|
|
53
|
+
unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
|
|
51
54
|
};
|
|
52
55
|
}
|
|
56
|
+
// B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
|
|
57
|
+
const tmpSweep = require('./utils/session-index-tmp-sweep');
|
|
53
58
|
|
|
54
59
|
/** Run one guarded check; a thrown fn becomes an error line. */
|
|
55
60
|
function guard(id, name, fn) {
|
|
@@ -224,6 +229,8 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
224
229
|
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
225
230
|
}));
|
|
226
231
|
|
|
232
|
+
checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
|
|
233
|
+
|
|
227
234
|
// #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
|
|
228
235
|
checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
|
|
229
236
|
const values = d.readApiKeyValues() || {};
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -68,9 +68,9 @@ async function handleStart(args) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const {
|
|
71
|
+
const { startAmicus } = require('./index');
|
|
72
72
|
|
|
73
|
-
return await
|
|
73
|
+
return await startAmicus({
|
|
74
74
|
taskId: args['task-id'],
|
|
75
75
|
model: args.model,
|
|
76
76
|
prompt: args.prompt,
|
|
@@ -212,9 +212,9 @@ async function handleRead(args) {
|
|
|
212
212
|
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error }));
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
const {
|
|
215
|
+
const { readAmicus } = require('./index');
|
|
216
216
|
|
|
217
|
-
await
|
|
217
|
+
await readAmicus({
|
|
218
218
|
taskId,
|
|
219
219
|
conversation: args.conversation,
|
|
220
220
|
metadata: args.metadata,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// src/cli-handlers-spend.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `amicus spend` — cross-run cost rollup over spend-ledger.jsonl (B24).
|
|
6
|
+
* Mirrors cli-handlers-doctor.js's injectable-deps shape (a `depsOverride`
|
|
7
|
+
* parameter tests can use to stub I/O/network) and cli-handlers-council.js's
|
|
8
|
+
* command-file split (kept its own file to stay well under the size gate).
|
|
9
|
+
*
|
|
10
|
+
* buildSpendDoc() lives HERE rather than in src/utils/result-schema.js: that
|
|
11
|
+
* module is at its 300-line size-gate ceiling (filled by a parallel lane in
|
|
12
|
+
* this same phase), so this module defines its own doc builder using the
|
|
13
|
+
* SAME schemaVersion convention (result-schema's SCHEMA_VERSION, imported —
|
|
14
|
+
* not a forked/independent counter) rather than adding to a full file. If
|
|
15
|
+
* result-schema.js is ever split/slimmed, buildSpendDoc is the one to fold
|
|
16
|
+
* back in alongside buildCatalogDoc/buildDoctorDoc.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { readSpendRows } = require('./utils/spend-ledger');
|
|
20
|
+
const { formatCost } = require('./utils/pricing');
|
|
21
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
22
|
+
|
|
23
|
+
const CREDIT_CHECK_TIMEOUT_MS = 5000;
|
|
24
|
+
|
|
25
|
+
/** @param {string} since e.g. '7d' @returns {number|null} whole days, or null if unparseable */
|
|
26
|
+
function parseSinceDays(since) {
|
|
27
|
+
if (typeof since !== 'string') { return null; }
|
|
28
|
+
const m = since.trim().match(/^(\d+)d$/i);
|
|
29
|
+
return m ? parseInt(m[1], 10) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emptyTokens() {
|
|
33
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addTokens(into, tokens) {
|
|
37
|
+
if (!tokens) { return; }
|
|
38
|
+
for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Aggregate ledger rows into a total + per-model rollup, most-expensive-first.
|
|
43
|
+
* A row with a null cost.amount contributes 0 to totals but is still counted
|
|
44
|
+
* in `runs` and its source bucket — visibility into "how many runs are
|
|
45
|
+
* unpriced" matters as much as the dollar figure.
|
|
46
|
+
* @param {Array<object>} rows
|
|
47
|
+
*/
|
|
48
|
+
function aggregateSpend(rows) {
|
|
49
|
+
const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
|
|
50
|
+
const byModelMap = new Map();
|
|
51
|
+
for (const r of rows) {
|
|
52
|
+
const model = r.model || 'unknown';
|
|
53
|
+
if (!byModelMap.has(model)) {
|
|
54
|
+
byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
|
|
55
|
+
}
|
|
56
|
+
const bucket = byModelMap.get(model);
|
|
57
|
+
bucket.runs += 1;
|
|
58
|
+
addTokens(bucket.tokens, r.tokens);
|
|
59
|
+
addTokens(total.tokens, r.tokens);
|
|
60
|
+
const cost = r.cost || {};
|
|
61
|
+
const amount = typeof cost.amount === 'number' ? cost.amount : 0;
|
|
62
|
+
bucket.amount += amount;
|
|
63
|
+
total.amount += amount;
|
|
64
|
+
// Any source string outside {reported,estimated} buckets as unknown —
|
|
65
|
+
// covers 'unknown', a missing/malformed cost block, or a future source.
|
|
66
|
+
const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
|
|
67
|
+
bucket.sourceMix[src] += 1;
|
|
68
|
+
total.sourceMix[src] += 1;
|
|
69
|
+
}
|
|
70
|
+
const byModel = [...byModelMap.values()].sort((a, b) => b.amount - a.amount);
|
|
71
|
+
return { total, byModel };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the `--json` spend document. schemaVersion reuses result-schema's
|
|
76
|
+
* SCHEMA_VERSION (not a forked counter) — see module docblock for why this
|
|
77
|
+
* builder lives here instead of alongside buildCatalogDoc/buildDoctorDoc.
|
|
78
|
+
* @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null}} opts
|
|
79
|
+
*/
|
|
80
|
+
function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
81
|
+
const { SCHEMA_VERSION } = require('./utils/result-schema');
|
|
82
|
+
return {
|
|
83
|
+
schemaVersion: SCHEMA_VERSION,
|
|
84
|
+
type: 'spend',
|
|
85
|
+
windowDays: windowDays !== undefined ? windowDays : null,
|
|
86
|
+
total,
|
|
87
|
+
byModel,
|
|
88
|
+
credit: credit || null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Real deps; tests override via the second handleSpend arg. */
|
|
93
|
+
function realDeps() {
|
|
94
|
+
return {
|
|
95
|
+
dir: undefined, // readSpendRows falls back to getConfigDir() itself
|
|
96
|
+
readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
|
|
97
|
+
checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
|
|
98
|
+
now: () => Date.now(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Race a promise against a hard timeout; resolves `fallback` on timeout — never rejects, never hangs the caller. */
|
|
103
|
+
function withTimeout(promise, ms, fallback) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
let settled = false;
|
|
106
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(fallback); } }, ms);
|
|
107
|
+
Promise.resolve(promise).then(
|
|
108
|
+
(v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } },
|
|
109
|
+
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(fallback); } },
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Best-effort OpenRouter credit footer. Never throws; never blocks past
|
|
116
|
+
* ~CREDIT_CHECK_TIMEOUT_MS; skipped (returns null) when no key is configured.
|
|
117
|
+
*/
|
|
118
|
+
async function fetchCreditFooter(deps) {
|
|
119
|
+
let values = {};
|
|
120
|
+
try { values = deps.readApiKeyValues() || {}; } catch { /* best-effort */ }
|
|
121
|
+
const key = values.openrouter;
|
|
122
|
+
if (!key) { return null; }
|
|
123
|
+
const res = await withTimeout(
|
|
124
|
+
Promise.resolve().then(() => deps.checkOpenRouterCredit(key)),
|
|
125
|
+
CREDIT_CHECK_TIMEOUT_MS,
|
|
126
|
+
null,
|
|
127
|
+
);
|
|
128
|
+
return res || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderHuman({ total, byModel, windowDays, credit }) {
|
|
132
|
+
if (total.runs === 0) { return 'No spend recorded yet.\n'; }
|
|
133
|
+
let out = windowDays ? `amicus spend (last ${windowDays}d)\n\n` : 'amicus spend (all time)\n\n';
|
|
134
|
+
out += 'model runs tokens(in/out) cost sources\n';
|
|
135
|
+
for (const m of byModel) {
|
|
136
|
+
const mix = `r${m.sourceMix.reported}/e${m.sourceMix.estimated}/u${m.sourceMix.unknown}`;
|
|
137
|
+
const tokCol = `${m.tokens.input}/${m.tokens.output}`;
|
|
138
|
+
out += `${String(m.model).slice(0, 48).padEnd(48)} ${String(m.runs).padStart(4)} ` +
|
|
139
|
+
`${tokCol.padStart(15)} ` +
|
|
140
|
+
`${formatCost({ amount: m.amount, source: dominantSource(m.sourceMix) }).padStart(9)} ${mix}\n`;
|
|
141
|
+
}
|
|
142
|
+
out += `\nTotal: ${formatCost({ amount: total.amount, source: dominantSource(total.sourceMix) })} across ${total.runs} run(s)\n`;
|
|
143
|
+
if (credit && typeof credit.limitRemaining === 'number') {
|
|
144
|
+
out += `OpenRouter credit remaining: $${credit.limitRemaining}\n`;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pick a representative source tag for formatCost's ~ prefix: mixed if >1 bucket populated. */
|
|
150
|
+
function dominantSource(mix) {
|
|
151
|
+
const populated = Object.entries(mix).filter(([, n]) => n > 0).map(([k]) => k);
|
|
152
|
+
if (populated.length === 0) { return 'unknown'; }
|
|
153
|
+
if (populated.length > 1) { return 'mixed'; }
|
|
154
|
+
return populated[0];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `amicus spend [--since 7d] [--json]`
|
|
159
|
+
* @param {{_:string[], json?:boolean, since?:string}} args
|
|
160
|
+
* @param {object} [depsOverride] test seam
|
|
161
|
+
* @returns {Promise<number>} exit code
|
|
162
|
+
*/
|
|
163
|
+
async function handleSpend(args, depsOverride = {}) {
|
|
164
|
+
const useJson = !!args.json;
|
|
165
|
+
const deps = { ...realDeps(), ...depsOverride };
|
|
166
|
+
|
|
167
|
+
let windowDays = null;
|
|
168
|
+
if (args.since !== undefined) {
|
|
169
|
+
windowDays = parseSinceDays(args.since);
|
|
170
|
+
if (windowDays === null) {
|
|
171
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `invalid --since '${args.since}'`,
|
|
172
|
+
hint: "amicus spend --since 7d (an integer followed by 'd')" });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let rows = readSpendRows(deps.dir);
|
|
177
|
+
if (windowDays !== null) {
|
|
178
|
+
const cutoff = deps.now() - windowDays * 86400000;
|
|
179
|
+
rows = rows.filter(r => {
|
|
180
|
+
const t = Date.parse(r.ts);
|
|
181
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { total, byModel } = aggregateSpend(rows);
|
|
186
|
+
// Nothing recorded (or nothing in the --since window): skip the network
|
|
187
|
+
// credit probe entirely — there's no rollup to attach it to either way.
|
|
188
|
+
const credit = total.runs === 0 ? null : await fetchCreditFooter(deps).catch(() => null);
|
|
189
|
+
|
|
190
|
+
if (useJson) {
|
|
191
|
+
process.stdout.write(JSON.stringify(buildSpendDoc({ total, byModel, windowDays, credit }), null, 2) + '\n');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
process.stdout.write(renderHuman({ total, byModel, windowDays, credit }));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays };
|
package/src/cli.js
CHANGED
|
@@ -94,6 +94,17 @@ function parseArgs(argv) {
|
|
|
94
94
|
} else {
|
|
95
95
|
result[key] = true;
|
|
96
96
|
}
|
|
97
|
+
} else if (arg === '-o') {
|
|
98
|
+
// Single short-flag alias, scoped to exactly '-o' (council verdict's
|
|
99
|
+
// --out shorthand). No general short-flag support is implemented —
|
|
100
|
+
// any other leading-dash token still falls through to positionals.
|
|
101
|
+
const next = argv[i + 1];
|
|
102
|
+
if (next && !next.startsWith('-')) {
|
|
103
|
+
result.out = next;
|
|
104
|
+
i++;
|
|
105
|
+
} else {
|
|
106
|
+
result.out = true;
|
|
107
|
+
}
|
|
97
108
|
} else {
|
|
98
109
|
result._.push(arg);
|
|
99
110
|
}
|
|
@@ -338,7 +349,10 @@ Commands:
|
|
|
338
349
|
council tally <input.json> [--json] Tally council findings → tiers/street-cred
|
|
339
350
|
council stats [--json] Reviewer-reliability from the ledger
|
|
340
351
|
council report <verdict.json> [--wave <wave.json>] [--md|--html] Disagreement+verdict report
|
|
352
|
+
council validate <file> [--json] Validate a Stage-1 findings block (exit 0/2/1)
|
|
353
|
+
council verdict <tally.json> [--decisions <d.json>] [-o <out.json>] Build + write verdict.json
|
|
341
354
|
doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
|
|
355
|
+
spend [--since 7d] [--json] Cross-run cost rollup from the spend ledger
|
|
342
356
|
abort Abort a running session (or --all)
|
|
343
357
|
setup Configure default model and aliases
|
|
344
358
|
--api-keys Open API key setup window
|
|
@@ -465,12 +479,33 @@ Subcommands for 'council':
|
|
|
465
479
|
--wave <wave.json> Include per-leg run stats from a wave file
|
|
466
480
|
--md Emit Markdown (default)
|
|
467
481
|
--html Emit a self-contained HTML page
|
|
482
|
+
validate <file> Validate a Stage-1 reviewer's findings block
|
|
483
|
+
--json Machine-readable output
|
|
484
|
+
Exit codes: 0 ok:true, 2 ok:false (validation failure), 1 BAD_ARGS
|
|
485
|
+
(missing/unreadable file)
|
|
486
|
+
verdict <tally.json> Build + write verdict.json (buildVerdict + atomic write)
|
|
487
|
+
--decisions <d.json> Optional. Stage-4 decisions array (default [])
|
|
488
|
+
-o, --out <out.json> Output path (default ./verdict.json)
|
|
489
|
+
--json Print the full verdict document
|
|
490
|
+
save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
|
|
491
|
+
--json Machine-readable output
|
|
492
|
+
list List saved councils plus the built-in benches
|
|
493
|
+
--json Machine-readable output
|
|
494
|
+
show <name> Resolve a council by name (saved or built-in)
|
|
495
|
+
--json Machine-readable output
|
|
468
496
|
`,
|
|
469
497
|
doctor: `
|
|
470
498
|
Options for 'doctor':
|
|
471
499
|
--json Machine-readable output
|
|
472
500
|
--fix Self-heal fixable checks in place (provisions the
|
|
473
501
|
Electron GUI binary; no global reinstall)
|
|
502
|
+
`,
|
|
503
|
+
spend: `
|
|
504
|
+
Options for 'spend':
|
|
505
|
+
--since <Nd> Restrict to the last N days (e.g. --since 7d)
|
|
506
|
+
--json Machine-readable output (versioned spend doc)
|
|
507
|
+
Reads ~/.config/amicus/spend-ledger.jsonl (one row per completed run/leg).
|
|
508
|
+
Shows remaining OpenRouter credit when a key is configured.
|
|
474
509
|
`,
|
|
475
510
|
setup: `
|
|
476
511
|
Options for 'setup':
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/council/presets-cli.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `amicus council save|list|show` — CLI wrappers around the council-preset
|
|
6
|
+
* primitives (src/utils/config.js councils.*, src/utils/council-presets.js
|
|
7
|
+
* built-in benches). Split out of cli-handlers-council.js to keep that file
|
|
8
|
+
* under the 300-line size gate.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { failJson, ERROR_CODES } = require('../utils/error-doc');
|
|
12
|
+
const { listBuiltinCouncilNames } = require('../utils/council-presets');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `amicus council save <name> --models a,b,c`
|
|
16
|
+
* Validates >=2 members, each resolvable via the same alias/catalog logic
|
|
17
|
+
* `resolveCouncilMembers` uses (effective aliases, or a raw `provider/model`
|
|
18
|
+
* id containing '/'). Overwrites an existing name with a notice — this is
|
|
19
|
+
* also how a user shadows a built-in bench of the same name.
|
|
20
|
+
* @param {string|undefined} name
|
|
21
|
+
* @param {string|undefined} modelsArg comma-separated aliases/ids
|
|
22
|
+
* @param {boolean} useJson
|
|
23
|
+
* @returns {number} exit code
|
|
24
|
+
*/
|
|
25
|
+
function runSave(name, modelsArg, useJson) {
|
|
26
|
+
if (!name) {
|
|
27
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council save needs a <name>',
|
|
28
|
+
hint: 'amicus council save <name> --models a,b,c' });
|
|
29
|
+
}
|
|
30
|
+
if (typeof modelsArg !== 'string' || !modelsArg.trim()) {
|
|
31
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council save needs --models a,b,c',
|
|
32
|
+
hint: 'amicus council save <name> --models a,b,c' });
|
|
33
|
+
}
|
|
34
|
+
const members = modelsArg.split(',').map(m => m.trim()).filter(Boolean);
|
|
35
|
+
if (members.length < 2) {
|
|
36
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'a council needs at least 2 members',
|
|
37
|
+
hint: 'pass --models with 2 or more comma-separated aliases or provider/model IDs' });
|
|
38
|
+
}
|
|
39
|
+
const { getEffectiveAliases, loadConfig, saveConfig, getCouncil } = require('../utils/config');
|
|
40
|
+
const aliases = getEffectiveAliases();
|
|
41
|
+
const unresolved = members.filter(m => !m.includes('/') && !aliases[m]);
|
|
42
|
+
if (unresolved.length) {
|
|
43
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
44
|
+
message: `unresolvable member(s): ${unresolved.join(', ')}`,
|
|
45
|
+
hint: 'each member must be a known alias (see `amicus models`) or a provider/model id containing "/"' });
|
|
46
|
+
}
|
|
47
|
+
const overwritten = !!getCouncil(name);
|
|
48
|
+
const cfg = loadConfig() || {};
|
|
49
|
+
if (!cfg.councils) { cfg.councils = {}; }
|
|
50
|
+
cfg.councils[name] = members;
|
|
51
|
+
saveConfig(cfg);
|
|
52
|
+
const doc = { ok: true, name, models: members, overwritten };
|
|
53
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderSave(doc));
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderSave(doc) {
|
|
58
|
+
const notice = doc.overwritten ? ' (overwritten)' : '';
|
|
59
|
+
return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `amicus council list [--json]` — user-saved councils plus the built-in
|
|
64
|
+
* benches (free/budget/frontier), each entry marked `builtin`. When a user
|
|
65
|
+
* council shares a name with a built-in, BOTH entries are listed: the user
|
|
66
|
+
* entry (builtin:false) is the one actually used by resolveCouncilMembers,
|
|
67
|
+
* and the built-in entry (builtin:true) is marked `shadowed:true`.
|
|
68
|
+
* @param {boolean} useJson
|
|
69
|
+
* @returns {number} exit code
|
|
70
|
+
*/
|
|
71
|
+
function runList(useJson) {
|
|
72
|
+
const { getCouncils } = require('../utils/config');
|
|
73
|
+
const userCouncils = getCouncils();
|
|
74
|
+
const userNames = new Set(Object.keys(userCouncils));
|
|
75
|
+
const entries = [];
|
|
76
|
+
for (const name of Object.keys(userCouncils).sort()) {
|
|
77
|
+
entries.push({ name, builtin: false, members: userCouncils[name] });
|
|
78
|
+
}
|
|
79
|
+
for (const name of listBuiltinCouncilNames()) {
|
|
80
|
+
const entry = { name, builtin: true };
|
|
81
|
+
if (userNames.has(name)) { entry.shadowed = true; }
|
|
82
|
+
entries.push(entry);
|
|
83
|
+
}
|
|
84
|
+
const doc = { councils: entries };
|
|
85
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderList(entries));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderList(entries) {
|
|
90
|
+
const lines = entries.map(e => {
|
|
91
|
+
if (e.builtin) { return ` ${e.name.padEnd(16)} [built-in]${e.shadowed ? ' (shadowed by a saved council of the same name)' : ''}`; }
|
|
92
|
+
return ` ${e.name.padEnd(16)} ${e.members.join(', ')}`;
|
|
93
|
+
});
|
|
94
|
+
return 'Councils:\n' + lines.join('\n') + '\n';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `amicus council show <name> [--json]` — resolves `name` exactly like
|
|
99
|
+
* `resolveCouncilMembers` (user config first, built-in fallback) and
|
|
100
|
+
* displays the raw members plus per-member resolution results (resolved /
|
|
101
|
+
* dropped). Unlike `resolveCouncilMembers` (which the run paths use, and
|
|
102
|
+
* which fails outright below 2 usable members), `show` is diagnostic-only:
|
|
103
|
+
* it always reports the full resolved/dropped split, even for a council
|
|
104
|
+
* that currently has fewer than 2 usable members.
|
|
105
|
+
* @param {string|undefined} name
|
|
106
|
+
* @param {boolean} useJson
|
|
107
|
+
* @returns {number} exit code
|
|
108
|
+
*/
|
|
109
|
+
function runShow(name, useJson) {
|
|
110
|
+
if (!name) {
|
|
111
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council show needs a <name>',
|
|
112
|
+
hint: 'amicus council show <name> [--json]' });
|
|
113
|
+
}
|
|
114
|
+
const { getCouncilWithSource, getEffectiveAliases } = require('../utils/config');
|
|
115
|
+
const { readCache } = require('../utils/model-catalog');
|
|
116
|
+
const catalog = (readCache() || {}).models || [];
|
|
117
|
+
const { members, builtin } = getCouncilWithSource(name, catalog);
|
|
118
|
+
if (!members) {
|
|
119
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Unknown council '${name}'`,
|
|
120
|
+
hint: "'amicus council list' shows available councils, or 'amicus council save' to create one" });
|
|
121
|
+
}
|
|
122
|
+
const aliases = getEffectiveAliases();
|
|
123
|
+
const resolved = [];
|
|
124
|
+
const dropped = [];
|
|
125
|
+
for (const member of members) {
|
|
126
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
127
|
+
(id ? resolved : dropped).push(member);
|
|
128
|
+
}
|
|
129
|
+
const doc = { name, builtin, members, resolved, dropped };
|
|
130
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderShow(doc));
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderShow(doc) {
|
|
135
|
+
const tag = doc.builtin ? ' [built-in]' : '';
|
|
136
|
+
let out = `Council '${doc.name}'${tag}\n members: ${doc.members.join(', ')}\n resolved: ${doc.resolved.join(', ')}\n`;
|
|
137
|
+
if (doc.dropped.length) { out += ` dropped: ${doc.dropped.join(', ')}\n`; }
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { runSave, runList, runShow };
|