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.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +19 -0
- package/CHANGELOG.md +86 -0
- package/LICENSE +22 -1
- package/README.md +14 -3
- package/bin/amicus.js +17 -162
- package/electron/ipc-setup.js +30 -9
- package/electron/main.js +13 -5
- package/electron/preload.js +30 -10
- package/electron/setup-ui-keys.js +9 -0
- package/electron/setup-ui-model.js +33 -23
- package/electron/setup-ui-styles.js +6 -1
- package/electron/setup-ui.js +91 -38
- package/electron/toolbar.js +4 -5
- package/package.json +7 -5
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +84 -51
- package/{skill → skills/sidecar}/SKILL.md +14 -4
- package/src/cli-handlers-council.js +59 -0
- package/src/cli-handlers-doctor.js +173 -0
- package/src/cli-handlers-run.js +196 -0
- package/src/cli-handlers.js +66 -1
- package/src/cli.js +16 -2
- package/src/council/findings.js +48 -0
- package/src/council/ledger.js +82 -0
- package/src/council/tally.js +108 -0
- package/src/council/verdict.js +48 -0
- package/src/headless.js +43 -149
- package/src/mcp-server.js +6 -0
- package/src/sidecar/budget.js +83 -0
- package/src/sidecar/conversation-mirror.js +128 -0
- package/src/sidecar/fanout-leg.js +4 -1
- package/src/sidecar/fanout.js +34 -7
- package/src/sidecar/interactive-mirror.js +66 -0
- package/src/sidecar/interactive.js +35 -21
- package/src/sidecar/models.js +41 -10
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +55 -42
- package/src/sidecar/start.js +19 -6
- package/src/utils/activity-poller.js +47 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/config.js +4 -4
- package/src/utils/curated-models.js +88 -45
- package/src/utils/error-doc.js +55 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/model-fetcher.js +16 -2
- package/src/utils/pricing.js +93 -0
- package/src/utils/quick-picks.js +81 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- 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 };
|
package/src/cli-handlers.js
CHANGED
|
@@ -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:
|
|
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 };
|