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.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +149 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +14 -20
  5. package/commands/council.md +3 -1
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +24 -23
  16. package/skills/sidecar/SKILL.md +3 -3
  17. package/src/cli-handlers-council.js +101 -1
  18. package/src/cli-handlers-doctor.js +7 -0
  19. package/src/cli-handlers-run.js +4 -4
  20. package/src/cli-handlers-spend.js +198 -0
  21. package/src/cli.js +35 -0
  22. package/src/council/presets-cli.js +141 -0
  23. package/src/headless.js +146 -38
  24. package/src/index.js +1 -9
  25. package/src/mcp-server.js +132 -108
  26. package/src/mcp-tools.js +27 -3
  27. package/src/mcp-wait.js +8 -5
  28. package/src/opencode-client.js +33 -10
  29. package/src/prompt-builder.js +32 -11
  30. package/src/session-manager.js +7 -14
  31. package/src/sidecar/continue.js +12 -5
  32. package/src/sidecar/conversation-mirror.js +22 -1
  33. package/src/sidecar/crash-handler.js +2 -1
  34. package/src/sidecar/fanout-leg.js +12 -3
  35. package/src/sidecar/fanout.js +27 -10
  36. package/src/sidecar/interactive-process.js +6 -17
  37. package/src/sidecar/interactive.js +5 -6
  38. package/src/sidecar/models.js +33 -4
  39. package/src/sidecar/progress.js +2 -1
  40. package/src/sidecar/read.js +4 -6
  41. package/src/sidecar/resume.js +19 -4
  42. package/src/sidecar/session-finalize.js +2 -1
  43. package/src/sidecar/session-utils.js +13 -35
  44. package/src/sidecar/setup-window.js +2 -3
  45. package/src/sidecar/start.js +22 -7
  46. package/src/utils/abort-coordinator.js +57 -7
  47. package/src/utils/api-key-store.js +2 -13
  48. package/src/utils/config.js +30 -43
  49. package/src/utils/council-presets.js +87 -0
  50. package/src/utils/env-loader.js +1 -2
  51. package/src/utils/fold-marker.js +79 -0
  52. package/src/utils/idle-watchdog.js +9 -12
  53. package/src/utils/lifecycle.js +1 -1
  54. package/src/utils/mcp-discovery.js +29 -5
  55. package/src/utils/mcp-self-identity.js +12 -5
  56. package/src/utils/model-catalog.js +54 -6
  57. package/src/utils/read-slice.js +73 -0
  58. package/src/utils/remediation-hints.js +9 -0
  59. package/src/utils/result-schema.js +8 -2
  60. package/src/utils/session-abort.js +1 -1
  61. package/src/utils/session-index-tmp-sweep.js +80 -0
  62. package/src/utils/session-index.js +4 -5
  63. package/src/utils/session-path.js +6 -10
  64. package/src/utils/shared-server.js +7 -5
  65. package/src/utils/spend-ledger.js +80 -0
  66. package/src/utils/updater.js +2 -3
  67. 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 || ''}'`, hint: 'amicus council tally|stats|report' });
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() || {};
@@ -68,9 +68,9 @@ async function handleStart(args) {
68
68
  }
69
69
  }
70
70
 
71
- const { startSidecar } = require('./index');
71
+ const { startAmicus } = require('./index');
72
72
 
73
- return await startSidecar({
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 { readSidecar } = require('./index');
215
+ const { readAmicus } = require('./index');
216
216
 
217
- await readSidecar({
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 };