amicus 1.0.0 → 1.2.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 (55) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +86 -0
  4. package/LICENSE +22 -1
  5. package/README.md +14 -3
  6. package/bin/amicus.js +17 -162
  7. package/electron/ipc-setup.js +30 -9
  8. package/electron/main.js +13 -5
  9. package/electron/preload.js +30 -10
  10. package/electron/setup-ui-keys.js +9 -0
  11. package/electron/setup-ui-model.js +33 -23
  12. package/electron/setup-ui-styles.js +6 -1
  13. package/electron/setup-ui.js +91 -38
  14. package/electron/toolbar.js +4 -5
  15. package/package.json +7 -5
  16. package/scripts/postinstall.js +16 -7
  17. package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
  18. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  19. package/skills/second-opinion/SKILL.md +84 -51
  20. package/{skill → skills/sidecar}/SKILL.md +14 -4
  21. package/src/cli-handlers-council.js +59 -0
  22. package/src/cli-handlers-doctor.js +173 -0
  23. package/src/cli-handlers-run.js +196 -0
  24. package/src/cli-handlers.js +66 -1
  25. package/src/cli.js +16 -2
  26. package/src/council/findings.js +48 -0
  27. package/src/council/ledger.js +82 -0
  28. package/src/council/tally.js +108 -0
  29. package/src/council/verdict.js +48 -0
  30. package/src/headless.js +43 -149
  31. package/src/mcp-server.js +6 -0
  32. package/src/sidecar/budget.js +83 -0
  33. package/src/sidecar/conversation-mirror.js +128 -0
  34. package/src/sidecar/fanout-leg.js +4 -1
  35. package/src/sidecar/fanout.js +34 -7
  36. package/src/sidecar/interactive-mirror.js +66 -0
  37. package/src/sidecar/interactive.js +35 -21
  38. package/src/sidecar/models.js +41 -10
  39. package/src/sidecar/session-finalize.js +26 -0
  40. package/src/sidecar/session-utils.js +5 -5
  41. package/src/sidecar/setup.js +55 -42
  42. package/src/sidecar/start.js +19 -6
  43. package/src/utils/activity-poller.js +47 -0
  44. package/src/utils/alias-resolver.js +1 -1
  45. package/src/utils/config.js +4 -4
  46. package/src/utils/curated-models.js +88 -45
  47. package/src/utils/error-doc.js +55 -0
  48. package/src/utils/lifecycle.js +1 -1
  49. package/src/utils/model-catalog.js +1 -1
  50. package/src/utils/model-fetcher.js +16 -2
  51. package/src/utils/pricing.js +93 -0
  52. package/src/utils/quick-picks.js +81 -0
  53. package/src/utils/result-schema.js +21 -2
  54. package/src/utils/session-abort.js +40 -13
  55. package/src/utils/validators.js +17 -17
@@ -0,0 +1,173 @@
1
+ // src/cli-handlers-doctor.js
2
+ 'use strict';
3
+
4
+ const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
5
+
6
+ /** Default real helpers; tests override via deps. */
7
+ function realDeps() {
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ return {
12
+ nodeVersion: process.version,
13
+ readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
14
+ getConfigDir: () => require('./utils/config').getConfigDir(),
15
+ resolveModel: () => require('./utils/config').resolveModel(),
16
+ readCache: () => require('./utils/model-catalog').readCache(),
17
+ collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
18
+ findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
19
+ hasOpencodeBinary: () => {
20
+ const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
21
+ ensureNodeModulesBinInPath();
22
+ const root = path.join(__dirname, '..', 'node_modules');
23
+ const candidates = process.platform === 'win32'
24
+ ? [path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}`, 'bin', 'opencode.exe'),
25
+ path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}-baseline`, 'bin', 'opencode.exe')]
26
+ : [path.join(root, '.bin', 'opencode')];
27
+ return candidates.some(p => fs.existsSync(p));
28
+ },
29
+ getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
30
+ discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
31
+ discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
32
+ skillInstalled: () => {
33
+ const dir = path.join(os.homedir(), '.claude', 'skills');
34
+ return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
35
+ && fs.existsSync(path.join(dir, 'second-opinion', 'SKILL.md'));
36
+ },
37
+ };
38
+ }
39
+
40
+ /** Run one guarded check; a thrown fn becomes an error line. */
41
+ function guard(id, name, fn) {
42
+ try { return fn(); }
43
+ catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
44
+ }
45
+
46
+ /**
47
+ * Compose the health checks. Never throws.
48
+ * @param {object} [depsOverride]
49
+ * @returns {Array<{id,name,status,message,hint}>}
50
+ */
51
+ function runDoctorChecks(depsOverride = {}) {
52
+ const d = { ...realDeps(), ...depsOverride };
53
+ const checks = [];
54
+
55
+ checks.push(guard('node', 'Node.js', () => {
56
+ const major = parseInt(String(d.nodeVersion).replace(/^v/, '').split('.')[0], 10);
57
+ return major >= 18
58
+ ? { id: 'node', name: 'Node.js', status: 'ok', message: d.nodeVersion, hint: null }
59
+ : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=18)`, hint: 'Install Node 18 or newer from https://nodejs.org' };
60
+ }));
61
+
62
+ checks.push(guard('config-dir', 'Config directory', () => (
63
+ { id: 'config-dir', name: 'Config directory', status: 'ok', message: d.getConfigDir(), hint: null }
64
+ )));
65
+
66
+ checks.push(guard('keys', 'API keys', () => {
67
+ const keys = d.readApiKeys();
68
+ const set = Object.keys(keys).filter(k => keys[k]);
69
+ return set.length > 0
70
+ ? { id: 'keys', name: 'API keys', status: 'ok', message: `configured: ${set.join(', ')}`, hint: null }
71
+ : { id: 'keys', name: 'API keys', status: 'error', message: 'no provider keys configured', hint: 'amicus key <provider> <key> (or run: amicus setup)' };
72
+ }));
73
+
74
+ checks.push((() => {
75
+ try {
76
+ const model = d.resolveModel();
77
+ return { id: 'default-model', name: 'Default model', status: 'ok', message: model, hint: null };
78
+ } catch (e) {
79
+ return { id: 'default-model', name: 'Default model', status: 'error', message: e.message || 'no default model', hint: 'amicus setup' };
80
+ }
81
+ })());
82
+
83
+ checks.push(guard('catalog', 'Model catalog', () => {
84
+ const cache = d.readCache();
85
+ if (!cache || !cache.fetchedAt) {
86
+ return { id: 'catalog', name: 'Model catalog', status: 'warn', message: 'no cache yet', hint: 'amicus models --refresh' };
87
+ }
88
+ const ageMs = Date.now() - cache.fetchedAt;
89
+ const fresh = ageMs <= MAX_CATALOG_AGE_MS;
90
+ const hrs = Math.round(ageMs / 3600000);
91
+ return fresh
92
+ ? { id: 'catalog', name: 'Model catalog', status: 'ok', message: `${cache.models.length} models, ${hrs}h old`, hint: null }
93
+ : { id: 'catalog', name: 'Model catalog', status: 'warn', message: `stale (${hrs}h old)`, hint: 'amicus models --refresh' };
94
+ }));
95
+
96
+ checks.push(guard('aliases', 'Model aliases', () => {
97
+ const cache = d.readCache();
98
+ const catalog = (cache && cache.models) || [];
99
+ const stale = d.findStaleAliases(d.collectAliasSources(), catalog);
100
+ return stale.length === 0
101
+ ? { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null }
102
+ : { id: 'aliases', name: 'Model aliases', status: 'warn', message: `${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`, hint: 'amicus models --check' };
103
+ }));
104
+
105
+ checks.push(guard('opencode-bin', 'OpenCode binary', () => (
106
+ d.hasOpencodeBinary()
107
+ ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
108
+ : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: 'npm install -g amicus' }
109
+ )));
110
+
111
+ checks.push(guard('electron', 'Electron (interactive GUI)', () => (
112
+ d.getElectronPath()
113
+ ? { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null }
114
+ : { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: 'npm install -g amicus (reinstall to add Electron)' }
115
+ )));
116
+
117
+ checks.push(guard('skills', 'Skills installed', () => (
118
+ d.skillInstalled()
119
+ ? { id: 'skills', name: 'Skills installed', status: 'ok', message: '~/.claude/skills/{sidecar,second-opinion}', hint: null }
120
+ : { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: 'npm install -g amicus (re-runs the skill install)' }
121
+ )));
122
+
123
+ checks.push(guard('mcp', 'MCP registration', () => {
124
+ const code = d.discoverClaudeCodeMcps();
125
+ const cowork = d.discoverCoworkMcps();
126
+ const inCode = !!(code && code.amicus);
127
+ const inCowork = !!(cowork && cowork.amicus);
128
+ // Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
129
+ if (!inCode) {
130
+ return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: 'npm install -g amicus (or install the amicus plugin)' };
131
+ }
132
+ const extra = inCowork ? ', Cowork/Desktop' : '';
133
+ return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
134
+ }));
135
+
136
+ return checks;
137
+ }
138
+
139
+ const MARK = { ok: '✓', warn: '⚠', error: '✗' }; // ✓ ⚠ ✗
140
+
141
+ function renderHuman(checks) {
142
+ let out = 'amicus doctor\n\n';
143
+ for (const c of checks) {
144
+ out += `${MARK[c.status] || '?'} ${c.name}: ${c.message}\n`;
145
+ if (c.hint && c.status !== 'ok') { out += ` → ${c.hint}\n`; }
146
+ }
147
+ const errors = checks.filter(c => c.status === 'error').length;
148
+ const warns = checks.filter(c => c.status === 'warn').length;
149
+ out += `\n${errors} error(s), ${warns} warning(s).\n`;
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * `amicus doctor [--json]`. Injectable `runChecks` for tests.
155
+ * @param {{_:string[], json?:boolean}} args
156
+ * @param {(deps?:object)=>Array} [runChecks]
157
+ * @returns {Promise<number>} exit code
158
+ */
159
+ async function handleDoctor(args, runChecks = runDoctorChecks) {
160
+ const useJson = !!args.json;
161
+ const checks = runChecks();
162
+ if (useJson) {
163
+ const { buildDoctorDoc } = require('./utils/result-schema');
164
+ const VERSION = require('../package.json').version;
165
+ const doc = buildDoctorDoc({ version: VERSION, timestamp: new Date().toISOString(), checks });
166
+ process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
167
+ return doc.ok ? 0 : 1;
168
+ }
169
+ process.stdout.write(renderHuman(checks));
170
+ return checks.some(c => c.status === 'error') ? 1 : 0;
171
+ }
172
+
173
+ module.exports = { runDoctorChecks, handleDoctor, MAX_CATALOG_AGE_MS };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * CLI Run Handlers (WS-2 extraction)
3
+ *
4
+ * Extracted from bin/amicus.js to keep the CLI entry point under the 300-line
5
+ * size gate and to make handlers unit-testable without running main().
6
+ *
7
+ * Contains: handleStart, handleFanout, handleRead
8
+ * Remaining inline in bin/amicus.js: handleList, handleResume, handleContinue
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { validateStartArgs } = require('./cli');
14
+ const { validateTaskId } = require('./utils/validators');
15
+ const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
16
+ const { failJson, ERROR_CODES } = require('./utils/error-doc');
17
+
18
+ /**
19
+ * Handle 'sidecar start' command
20
+ * Spec Reference: §4.1
21
+ */
22
+ async function handleStart(args) {
23
+ const useJson = !!args.json;
24
+
25
+ // F4: --prompt-file support (XOR --prompt) and --json gating
26
+ if (args.prompt !== undefined || args['prompt-file'] !== undefined) {
27
+ const { resolvePromptSource } = require('./utils/prompt-source');
28
+ const promptRes = resolvePromptSource(args);
29
+ if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
30
+ args.prompt = promptRes.prompt;
31
+ }
32
+ if (args.json && !args['no-ui']) {
33
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
34
+ }
35
+
36
+ const mc = args['max-cost'];
37
+ if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
38
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
39
+ }
40
+
41
+ const { model, alias } = resolveModelFromArgs(args);
42
+ args.model = model;
43
+ args.model = await validateFallbackModel(args, alias);
44
+
45
+ // Normalize agent: --agent takes precedence, otherwise use --mode
46
+ args.agent = args.agent || args.mode;
47
+
48
+ const validation = validateStartArgs(args);
49
+ if (!validation.valid) {
50
+ process.exit(failJson(useJson, { code: validation.code || ERROR_CODES.BAD_ARGS, message: validation.error }));
51
+ }
52
+
53
+ // Budget gate for solo start
54
+ if (!args['no-cost-gate']) {
55
+ const { lookupPricing } = require('./utils/pricing');
56
+ const { checkBudget, formatBudgetError } = require('./sidecar/budget');
57
+ const { loadConfig } = require('./utils/config');
58
+ const cfg = loadConfig() || {};
59
+ const soloLeg = { modelInput: alias || args.model, model: args.model, pricing: lookupPricing(args.model) };
60
+ const promptChars = (args.prompt && String(args.prompt).length) || 0;
61
+ const budget = checkBudget([soloLeg], { maxCostPerMtok: cfg.maxCostPerMtok, maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost, promptChars });
62
+ if (!budget.ok) {
63
+ process.exit(failJson(useJson, { code: ERROR_CODES.BUDGET_EXCEEDED, message: 'Error: budget gate refused the run', hint: formatBudgetError(budget) }));
64
+ }
65
+ }
66
+
67
+ const { startSidecar } = require('./index');
68
+
69
+ return await startSidecar({
70
+ taskId: args['task-id'],
71
+ model: args.model,
72
+ prompt: args.prompt,
73
+ sessionId: args['session-id'],
74
+ cwd: args.cwd,
75
+ contextTurns: args['context-turns'],
76
+ contextSince: args['context-since'],
77
+ contextMaxTokens: args['context-max-tokens'],
78
+ noUi: args['no-ui'],
79
+ timeout: args.timeout,
80
+ agent: args.agent,
81
+ mcp: args.mcp,
82
+ mcpConfig: args['mcp-config'],
83
+ thinking: args.thinking,
84
+ summaryLength: args['summary-length'],
85
+ client: args.client,
86
+ sessionDir: args['session-dir'],
87
+ foldShortcut: args['fold-shortcut'],
88
+ opencodePort: args['opencode-port'],
89
+ noMcp: args['no-mcp'],
90
+ excludeMcp: args['exclude-mcp'],
91
+ coworkProcess: args['cowork-process'],
92
+ position: args.position,
93
+ json: !!args.json,
94
+ modelInput: alias || null,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Handle 'amicus fanout' command (F4).
100
+ * Returns the wave exit code: 0 all complete, 2 partial, 1 none/hard failure,
101
+ * 130/143 when the wave was signal-aborted.
102
+ */
103
+ async function handleFanout(args) {
104
+ const useJson = !!args.json;
105
+
106
+ const { resolvePromptSource } = require('./utils/prompt-source');
107
+ const promptRes = resolvePromptSource(args);
108
+ if (promptRes.error) {
109
+ process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
110
+ }
111
+ if (typeof args.models !== 'string' || !args.models.trim()) {
112
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models is required (comma-separated aliases or provider/model IDs)' }));
113
+ }
114
+ if (args['wave-id']) {
115
+ const check = validateTaskId(String(args['wave-id']));
116
+ if (!check.valid) {
117
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
118
+ }
119
+ }
120
+ if (args.agent && String(args.agent).toLowerCase() === 'chat') {
121
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --agent chat is interactive-only; fanout is headless' }));
122
+ }
123
+ if (args.timeout !== undefined && args.timeout <= 0) {
124
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --timeout must be a positive number' }));
125
+ }
126
+ const mc = args['max-cost'];
127
+ if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
128
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --max-cost must be a positive number' }));
129
+ }
130
+ const { parseModelsList } = require('./sidecar/fanout');
131
+ if (parseModelsList(args.models).length === 0) {
132
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --models must contain at least one non-empty entry' }));
133
+ }
134
+
135
+ // Direct require — the src/index.js public re-export is added later (Task 13)
136
+ const { runFanout } = require('./sidecar/fanout');
137
+ const { loadConfig } = require('./utils/config');
138
+ const cfg = loadConfig() || {};
139
+ const { exitCode } = await runFanout({
140
+ models: args.models,
141
+ prompt: promptRes.prompt,
142
+ promptMeta: promptRes.promptMeta,
143
+ waveId: args['wave-id'],
144
+ project: args.cwd || process.cwd(),
145
+ agent: args.agent || args.mode,
146
+ thinking: args.thinking,
147
+ timeout: args.timeout,
148
+ summaryLength: args['summary-length'],
149
+ includeContext: !args['no-context'],
150
+ sessionId: args['session-id'],
151
+ contextTurns: args['context-turns'],
152
+ contextSince: args['context-since'],
153
+ contextMaxTokens: args['context-max-tokens'],
154
+ mcp: args.mcp,
155
+ mcpConfig: args['mcp-config'],
156
+ noMcp: args['no-mcp'],
157
+ excludeMcp: args['exclude-mcp'],
158
+ noValidateModel: args['no-validate-model'],
159
+ json: !!args.json,
160
+ client: args.client,
161
+ maxCost: args['max-cost'] !== null && args['max-cost'] !== undefined ? args['max-cost'] : cfg.maxCost,
162
+ noCostGate: !!args['no-cost-gate'],
163
+ maxCostPerMtok: cfg.maxCostPerMtok,
164
+ });
165
+ return exitCode;
166
+ }
167
+
168
+ /**
169
+ * Handle 'sidecar read' command
170
+ * Spec Reference: §4.5
171
+ */
172
+ async function handleRead(args) {
173
+ const useJson = !!args.json;
174
+ const taskId = args._[1];
175
+
176
+ if (!taskId) {
177
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: 'Error: task_id is required for read' }));
178
+ }
179
+
180
+ const taskIdCheck = validateTaskId(taskId);
181
+ if (!taskIdCheck.valid) {
182
+ process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error }));
183
+ }
184
+
185
+ const { readSidecar } = require('./index');
186
+
187
+ await readSidecar({
188
+ taskId,
189
+ conversation: args.conversation,
190
+ metadata: args.metadata,
191
+ json: args.json,
192
+ project: args.cwd
193
+ });
194
+ }
195
+
196
+ module.exports = { handleStart, handleFanout, handleRead };
@@ -97,7 +97,7 @@ async function handleAbort(args) {
97
97
 
98
98
  if (!taskId) {
99
99
  console.error('Error: task_id is required for abort');
100
- console.error('Usage: sidecar abort <task_id>');
100
+ console.error('Usage: amicus abort <task_id>');
101
101
  process.exit(1);
102
102
  }
103
103
 
@@ -180,9 +180,74 @@ async function handleMcp() {
180
180
  await startMcpServer();
181
181
  }
182
182
 
183
+ /**
184
+ * Handle 'amicus key' command
185
+ * Lists, saves, or removes API keys for a provider without opening the Electron wizard.
186
+ */
187
+ async function handleKey(args) {
188
+ const { readApiKeys, readApiKeyHints, saveApiKey, removeApiKey, PROVIDER_ENV_MAP } = require('./utils/api-key-store');
189
+ const { validateApiKey } = require('./utils/api-key-validation');
190
+
191
+ const provider = args._[1];
192
+ const keyArg = args._[2];
193
+
194
+ // List mode: no provider given
195
+ if (!provider) {
196
+ const configured = readApiKeys();
197
+ const hints = readApiKeyHints();
198
+ const knownProviders = Object.keys(PROVIDER_ENV_MAP);
199
+ console.log('');
200
+ console.log('Configured API keys:');
201
+ for (const p of knownProviders) {
202
+ const status = configured[p] ? `✓ ${hints[p]}` : '✗ not set';
203
+ console.log(` ${p.padEnd(12)} ${status}`);
204
+ }
205
+ console.log('');
206
+ return;
207
+ }
208
+
209
+ // Validate provider
210
+ if (!PROVIDER_ENV_MAP[provider]) {
211
+ console.error(`Error: Unknown provider "${provider}". Known providers: ${Object.keys(PROVIDER_ENV_MAP).join(', ')}`);
212
+ process.exit(1);
213
+ }
214
+
215
+ // Remove mode
216
+ if (args.remove) {
217
+ const result = removeApiKey(provider);
218
+ if (!result.success) {
219
+ console.error(`Error: ${result.error}`);
220
+ process.exit(1);
221
+ }
222
+ console.log(`${provider} key removed.`);
223
+ return;
224
+ }
225
+
226
+ // Save mode: key required
227
+ if (!keyArg) {
228
+ console.error(`Error: API key is required. Usage: amicus key ${provider} <apikey>`);
229
+ process.exit(1);
230
+ }
231
+
232
+ console.log(`Validating ${provider} key...`);
233
+ const validation = await validateApiKey(provider, keyArg);
234
+ if (!validation.valid) {
235
+ console.error(`Error: ${validation.error}`);
236
+ process.exit(1);
237
+ }
238
+
239
+ const result = saveApiKey(provider, keyArg);
240
+ if (!result.success) {
241
+ console.error(`Error: ${result.error}`);
242
+ process.exit(1);
243
+ }
244
+ console.log(`${provider} key validated and saved.`);
245
+ }
246
+
183
247
  module.exports = {
184
248
  handleSetup,
185
249
  handleAbort,
186
250
  handleUpdate,
187
251
  handleMcp,
252
+ handleKey,
188
253
  };
package/src/cli.js CHANGED
@@ -110,7 +110,9 @@ function isBooleanFlag(key) {
110
110
  'help',
111
111
  'api-keys',
112
112
  'validate-model',
113
- 'no-validate-model'
113
+ 'no-validate-model',
114
+ 'remove', // used by 'key' command only; other handlers ignore it
115
+ 'no-cost-gate', // disable the budget gate for this run
114
116
  ];
115
117
  return booleanFlags.includes(key);
116
118
  }
@@ -119,6 +121,9 @@ function isBooleanFlag(key) {
119
121
  * Parse a value to the appropriate type
120
122
  */
121
123
  function parseValue(key, value) {
124
+ // max-cost is a float (dollars), not an integer
125
+ if (key === 'max-cost') { return parseFloat(value); }
126
+
122
127
  // Numeric options
123
128
  const numericOptions = ['context-turns', 'context-max-tokens', 'timeout', 'opencode-port'];
124
129
  if (numericOptions.includes(key)) {
@@ -157,7 +162,7 @@ function validateStartArgs(args) {
157
162
 
158
163
  // Validate model format if model is present (model is resolved externally via resolveModel)
159
164
  if (args.model && !isValidModelFormat(args.model)) {
160
- return { valid: false, error: 'Error: --model must be in format provider/model (e.g., google/gemini-2.5-flash) or openrouter/provider/model' };
165
+ return { valid: false, code: 'BAD_MODEL', error: 'Error: --model must be in format provider/model (e.g., google/gemini-2.5-flash) or openrouter/provider/model' };
161
166
  }
162
167
 
163
168
  // Validate cwd path exists (if provided)
@@ -298,10 +303,17 @@ Commands:
298
303
  continue New session building on previous
299
304
  read Output session summary/conversation
300
305
  models List/search the model catalog, refresh it, audit aliases
306
+ council tally <input.json> [--json] Tally council findings → tiers/street-cred
307
+ council stats [--json] Reviewer-reliability from the ledger
308
+ doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
301
309
  abort Abort a running session (or --all)
302
310
  setup Configure default model and aliases
303
311
  --api-keys Open API key setup window
304
312
  --add-alias <name=model> Add a model alias without the full wizard
313
+ key Manage API keys from the command line
314
+ <provider> <apikey> Validate and save a key
315
+ <provider> --remove Remove a saved key
316
+ (no args) List all configured providers
305
317
  update Update to latest version
306
318
  mcp Start MCP server (stdio transport)
307
319
 
@@ -347,6 +359,8 @@ Options for 'fanout':
347
359
  with --prompt. Also works with 'start'.
348
360
  --wave-id <id> Explicit wave ID (leg IDs become <id>-1..N)
349
361
  --json Emit the wave result as stable JSON on stdout
362
+ --max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
363
+ --no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
350
364
  Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
351
365
  --no-context, --context-*, --mcp*, --no-validate-model, --cwd
352
366
  Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
@@ -0,0 +1,48 @@
1
+ // src/council/findings.js
2
+ 'use strict';
3
+
4
+ const SEVERITIES = ['blocker', 'major', 'minor', 'nit'];
5
+ const REQUIRED = ['claim', 'location', 'rationale'];
6
+
7
+ /** Extract the LAST ```json fenced block's body, or null. */
8
+ function lastJsonBlock(text) {
9
+ const re = /```json\s*\n([\s\S]*?)```/g;
10
+ let m, last = null;
11
+ while ((m = re.exec(text)) !== null) { last = m[1]; }
12
+ return last;
13
+ }
14
+
15
+ /**
16
+ * Validate a Stage-1 reviewer's fenced findings JSON.
17
+ * @param {string} jsonText full review text (prose + fenced block)
18
+ * @returns {{ok:boolean, findings:Array, errors:Array<{code:string,detail:string}>}}
19
+ */
20
+ function validateFindings(jsonText) {
21
+ const errors = [];
22
+ const body = lastJsonBlock(jsonText || '');
23
+ if (body === null) {
24
+ return { ok: false, findings: [], errors: [{ code: 'NO_FENCED_BLOCK', detail: 'no ```json block found' }] };
25
+ }
26
+ let parsed;
27
+ try { parsed = JSON.parse(body); }
28
+ catch (e) { return { ok: false, findings: [], errors: [{ code: 'NOT_PARSEABLE', detail: e.message }] }; }
29
+
30
+ const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
31
+ if (findings.length === 0) {
32
+ errors.push({ code: 'EMPTY_FINDINGS', detail: 'findings is missing or empty' });
33
+ }
34
+ const seen = new Set();
35
+ findings.forEach((f, i) => {
36
+ if (seen.has(f.id)) { errors.push({ code: 'DUPLICATE_ID', detail: `id ${f.id} repeats` }); }
37
+ seen.add(f.id);
38
+ if (f.id !== i + 1) { errors.push({ code: 'NON_SEQUENTIAL_ID', detail: `expected id ${i + 1}, got ${f.id}` }); }
39
+ if (!SEVERITIES.includes(f.severity)) { errors.push({ code: 'BAD_SEVERITY', detail: `bad severity '${f.severity}' on id ${f.id}` }); }
40
+ for (const k of REQUIRED) {
41
+ if (typeof f[k] !== 'string' || f[k].trim() === '') { errors.push({ code: 'MISSING_FIELD', detail: `missing ${k} on id ${f.id}` }); }
42
+ }
43
+ });
44
+
45
+ return { ok: errors.length === 0, findings: errors.length === 0 ? findings : [], errors };
46
+ }
47
+
48
+ module.exports = { validateFindings, SEVERITIES };
@@ -0,0 +1,82 @@
1
+ // src/council/ledger.js
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getConfigDir } = require('../utils/config');
6
+
7
+ const LEDGER_SCHEMA_VERSION = 1;
8
+ const LEDGER_FILE = 'council-ledger.jsonl';
9
+
10
+ function countSeverity(findings) {
11
+ const c = { blocker: 0, major: 0, minor: 0, nit: 0 };
12
+ for (const f of findings) { if (c[f.severity] !== undefined) { c[f.severity] += 1; } }
13
+ return c;
14
+ }
15
+
16
+ /** One model-level row per council model. Rates are over RAW raised findings. */
17
+ function buildLedgerRows(record) {
18
+ const { meta, findings, streetCred, runStats, judged } = record;
19
+ const sc = new Map(streetCred.map(s => [s.model, s]));
20
+ const rs = new Map(runStats.map(r => [r.model, r]));
21
+ return meta.models.map(model => {
22
+ const raised = findings.filter(f => f.raiser === model);
23
+ const s = sc.get(model) || {};
24
+ const r = rs.get(model) || {};
25
+ const denom = raised.length;
26
+ return {
27
+ schemaVersion: LEDGER_SCHEMA_VERSION,
28
+ runId: meta.runId, date: meta.date, runType: meta.runType, model,
29
+ role: r.role || 'council', wasChair: !!r.wasChair, judged: judged === true,
30
+ streetCredWithSelf: judged ? (s.withSelf ?? null) : null,
31
+ streetCredPeersOnly: judged ? (s.peersOnly ?? null) : null,
32
+ findingsRaised: denom,
33
+ bySeverity: countSeverity(raised),
34
+ confirmRate: judged && denom ? raised.filter(f => f.tier === 'Confirmed').length / denom : null,
35
+ factErrorRate: judged && denom ? raised.filter(f => f.tier === 'Disputed').length / denom : null,
36
+ conformance: r.conformance || 'clean',
37
+ };
38
+ });
39
+ }
40
+
41
+ function appendRun(record, opts = {}) {
42
+ const dir = opts.dir || getConfigDir();
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const file = path.join(dir, LEDGER_FILE);
45
+ const rows = buildLedgerRows(record);
46
+ for (const row of rows) { fs.appendFileSync(file, JSON.stringify(row) + '\n'); }
47
+ return rows;
48
+ }
49
+
50
+ function readRows(dir) {
51
+ const file = path.join(dir, LEDGER_FILE);
52
+ if (!fs.existsSync(file)) { return []; }
53
+ return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
54
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
55
+ }
56
+
57
+ function avg(nums) { return nums.length ? nums.reduce((s, x) => s + x, 0) / nums.length : null; }
58
+
59
+ /** Aggregate the ledger per model. peersOnly nulls excluded; lowN flags < 3 runs. */
60
+ function deriveReliability(opts = {}) {
61
+ const dir = opts.dir || getConfigDir();
62
+ const byModel = new Map();
63
+ for (const row of readRows(dir)) {
64
+ if (!byModel.has(row.model)) { byModel.set(row.model, []); }
65
+ byModel.get(row.model).push(row);
66
+ }
67
+ return [...byModel.entries()].map(([model, rows]) => {
68
+ const peers = rows.map(r => r.streetCredPeersOnly).filter(v => typeof v === 'number');
69
+ const confirms = rows.map(r => r.confirmRate).filter(v => typeof v === 'number');
70
+ const facts = rows.map(r => r.factErrorRate).filter(v => typeof v === 'number');
71
+ const conformance = rows.reduce((acc, r) => { acc[r.conformance] = (acc[r.conformance] || 0) + 1; return acc; }, {});
72
+ return {
73
+ model, runs: rows.length, lowN: rows.length < 3,
74
+ avgStreetCredPeersOnly: avg(peers),
75
+ lifetimeConfirmRate: avg(confirms),
76
+ lifetimeFactErrorRate: avg(facts),
77
+ conformance,
78
+ };
79
+ });
80
+ }
81
+
82
+ module.exports = { buildLedgerRows, appendRun, deriveReliability, LEDGER_FILE, LEDGER_SCHEMA_VERSION };