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
package/electron/fold.js CHANGED
@@ -9,6 +9,7 @@ const { logger } = require('../src/utils/logger');
9
9
  const { requestSummaryFromModel } = require('./summary');
10
10
  const { getSummaryTemplate } = require('../src/prompt-builder');
11
11
  const { tokenCss } = require('../src/design/tokens');
12
+ const { buildFoldMarker, generateFoldNonce } = require('../src/utils/fold-marker');
12
13
 
13
14
  /**
14
15
  * Create a fold handler bound to the window state
@@ -19,9 +20,17 @@ const { tokenCss } = require('../src/design/tokens');
19
20
  * @param {string} state.sessionId - OpenCode session ID
20
21
  * @param {string} state.taskId - Sidecar task ID
21
22
  * @param {number} state.port - OpenCode server port
23
+ * @param {string} [state.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). Set by
24
+ * main.js from AMICUS_FOLD_NONCE — the SAME value baked into the system prompt's
25
+ * fold instruction, so a completion this handler writes matches what the model was
26
+ * actually asked to emit. Falls back to a freshly generated nonce when absent (e.g.
27
+ * a caller/test that doesn't thread one through) — this is purely defensive: the
28
+ * GUI fold path is exit-code driven, not marker-detected, so an un-advertised
29
+ * fallback nonce here cannot be exploited the way headless.js's detector could.
22
30
  * @returns {{ triggerFold: Function, hasFolded: Function, isFolding: Function, hasCompleted: Function }}
23
31
  */
24
32
  function createFoldHandler(state) {
33
+ const nonce = state.nonce || generateFoldNonce();
25
34
  // `folded` is set synchronously at triggerFold ENTRY and covers both
26
35
  // "in flight" and "done" — this is `hasFolded()`'s existing external
27
36
  // contract (main.js wires it straight into createCloseGuard's `hasFolded`
@@ -52,7 +61,7 @@ function createFoldHandler(state) {
52
61
  }
53
62
 
54
63
  const output = [
55
- '[SIDECAR_FOLD]',
64
+ buildFoldMarker(nonce),
56
65
  `Model: ${state.model}`,
57
66
  `Session: ${state.sessionId || state.taskId}`,
58
67
  `Client: ${state.client}`,
@@ -4,7 +4,10 @@
4
4
  * Extracted from main.js to keep file sizes under 300 lines.
5
5
  * Registers all setup-mode IPC handlers: validate-key, save-key,
6
6
  * remove-key, setup-done, save-config, get-config, get-api-keys,
7
- * fetch-models, get-catalog, and refresh-catalog.
7
+ * get-catalog, and refresh-catalog.
8
+ * (sidecar:fetch-models was retired in B33/#12 — Step 3's alias editor now
9
+ * shares the TTL-cached get-catalog data Step 2 loads instead of a second,
10
+ * uncached live fetch.)
8
11
  */
9
12
 
10
13
  const { ipcMain } = require('electron');
@@ -160,19 +163,6 @@ function registerSetupHandlers(getMainWindow) {
160
163
  }
161
164
  });
162
165
 
163
- ipcMain.handle('sidecar:fetch-models', async () => {
164
- try {
165
- const { readApiKeyValues } = require('../src/utils/api-key-store');
166
- const { fetchAllModels, groupModelsByFamily } = require('../src/utils/model-fetcher');
167
- const keys = readApiKeyValues();
168
- const models = await fetchAllModels(keys);
169
- return groupModelsByFamily(models);
170
- } catch (err) {
171
- logger.error('fetch-models handler error', { error: err.message });
172
- return [];
173
- }
174
- });
175
-
176
166
  // F5: wizard Step 2 reads the catalog CACHE (self-refreshing when stale).
177
167
  ipcMain.handle('sidecar:get-catalog', async () => {
178
168
  try {
@@ -206,7 +196,12 @@ function registerSetupHandlers(getMainWindow) {
206
196
  const catalog = await getCatalog();
207
197
  const free = listFreeModels(catalog);
208
198
  const suggested = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
209
- return free.map(r => ({ id: r.id, suggested: suggested.has(r.id) }));
199
+ return free.map(r => ({
200
+ id: r.id,
201
+ suggested: suggested.has(r.id),
202
+ name: r.name,
203
+ vendor: r.id.split('/')[1] || '',
204
+ }));
210
205
  } catch (_err) { return []; }
211
206
  });
212
207
  }
package/electron/main.js CHANGED
@@ -15,7 +15,6 @@
15
15
  const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, screen } = require('electron');
16
16
  const path = require('path');
17
17
  const { logger } = require('../src/utils/logger');
18
- const { getCompatEnv } = require('../src/utils/env-compat');
19
18
  const { buildToolbarHTML, TOOLBAR_H, getBrandName } = require('./toolbar');
20
19
  const { createFoldHandler } = require('./fold');
21
20
  const { createCloseGuard } = require('./close-guard');
@@ -58,20 +57,25 @@ process.on('unhandledRejection', (reason) => {
58
57
  // Configuration from Environment (set by src/sidecar/start.js)
59
58
  // ============================================================================
60
59
 
61
- const MODE = getCompatEnv('MODE') || 'sidecar';
62
- const TASK_ID = getCompatEnv('TASK_ID') || 'unknown';
63
- const MODEL = getCompatEnv('MODEL') || 'unknown';
64
- const CWD = getCompatEnv('CWD') || process.cwd();
60
+ const MODE = process.env.AMICUS_MODE || 'sidecar';
61
+ const TASK_ID = process.env.AMICUS_TASK_ID || 'unknown';
62
+ const MODEL = process.env.AMICUS_MODEL || 'unknown';
63
+ const CWD = process.env.AMICUS_CWD || process.cwd();
65
64
  // The directory the OpenCode session was actually scoped to (#45). Set by the
66
65
  // interactive launcher as canonicalProjectPath(--cwd) so the Web-UI route is
67
66
  // built from the SAME directory createSession used. Falls back to CWD for
68
67
  // back-compat with launchers that predate this env var.
69
- const SESSION_DIRECTORY = getCompatEnv('SESSION_DIRECTORY') || CWD;
70
- const CLIENT = getCompatEnv('CLIENT') || 'code-local';
71
- const OPENCODE_PORT = parseInt(getCompatEnv('OPENCODE_PORT') || '4096', 10);
72
- const OPENCODE_SESSION_ID = getCompatEnv('SESSION_ID');
73
- const FOLD_SHORTCUT = getCompatEnv('FOLD_SHORTCUT') || 'CommandOrControl+Shift+F';
74
- const WINDOW_POSITION = getCompatEnv('WINDOW_POSITION') || 'right';
68
+ const SESSION_DIRECTORY = process.env.AMICUS_SESSION_DIRECTORY || CWD;
69
+ const CLIENT = process.env.AMICUS_CLIENT || 'code-local';
70
+ const OPENCODE_PORT = parseInt(process.env.AMICUS_OPENCODE_PORT || '4096', 10);
71
+ const OPENCODE_SESSION_ID = process.env.AMICUS_SESSION_ID;
72
+ const FOLD_SHORTCUT = process.env.AMICUS_FOLD_SHORTCUT || 'CommandOrControl+Shift+F';
73
+ const WINDOW_POSITION = process.env.AMICUS_WINDOW_POSITION || 'right';
74
+ // 15b.3: per-run fold nonce (#BL-7 residual). Set by the interactive launcher
75
+ // (src/sidecar/interactive-process.js buildElectronEnv) from the SAME value
76
+ // baked into the system prompt's fold instruction. undefined when a launcher
77
+ // predates this env var — fold.js falls back to the legacy bare marker.
78
+ const FOLD_NONCE = process.env.AMICUS_FOLD_NONCE;
75
79
 
76
80
  const OPENCODE_URL = `http://localhost:${OPENCODE_PORT}`;
77
81
 
@@ -89,7 +93,8 @@ const foldHandler = createFoldHandler({
89
93
  cwd: CWD,
90
94
  sessionId: OPENCODE_SESSION_ID,
91
95
  taskId: TASK_ID,
92
- port: OPENCODE_PORT
96
+ port: OPENCODE_PORT,
97
+ nonce: FOLD_NONCE
93
98
  });
94
99
  // Auto-fold on close (backlog B01): a user-initiated window close with no
95
100
  // fold yet run must not silently discard the session summary. See
@@ -128,7 +133,7 @@ function createAmicusWindow() {
128
133
 
129
134
  // Check for updates: prefer env var from CLI (cache is one-shot), fallback to direct check
130
135
  let updateInfo = null;
131
- const updateInfoRaw = getCompatEnv('UPDATE_INFO');
136
+ const updateInfoRaw = process.env.AMICUS_UPDATE_INFO;
132
137
  if (updateInfoRaw) {
133
138
  try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {}
134
139
  }
@@ -201,7 +206,7 @@ function createAmicusWindow() {
201
206
  // silently hung process (the historical "Starting up... | 0 messages" bug).
202
207
  const failsafe = attachLoadFailsafe({
203
208
  webContents: contentView.webContents,
204
- timeoutMs: parseInt(getCompatEnv('GUI_LOAD_TIMEOUT_MS') || '', 10) || undefined,
209
+ timeoutMs: parseInt(process.env.AMICUS_GUI_LOAD_TIMEOUT_MS || '', 10) || undefined,
205
210
  onFail: ({ reason, errorCode, errorDescription, validatedURL }) => {
206
211
  logger.error('OpenCode UI failed to load', {
207
212
  reason, errorCode, errorDescription, validatedURL, url: contentUrl
@@ -217,7 +222,7 @@ function createAmicusWindow() {
217
222
  // On timeout, show whatever is in flight rather than aborting the load.
218
223
  mainWindow.addBrowserView(contentView);
219
224
  updateContentBounds();
220
- if (!process.env.SIDECAR_HEADLESS_TEST) {
225
+ if (!process.env.AMICUS_HEADLESS_TEST) {
221
226
  mainWindow.show();
222
227
  }
223
228
  }
@@ -234,7 +239,7 @@ function createAmicusWindow() {
234
239
  failsafe.cancel();
235
240
  mainWindow.addBrowserView(contentView);
236
241
  updateContentBounds();
237
- if (!process.env.SIDECAR_HEADLESS_TEST) {
242
+ if (!process.env.AMICUS_HEADLESS_TEST) {
238
243
  mainWindow.show();
239
244
  }
240
245
  });
@@ -20,7 +20,6 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
20
20
  'sidecar:save-config',
21
21
  'sidecar:get-config',
22
22
  'sidecar:get-api-keys',
23
- 'sidecar:fetch-models',
24
23
  'sidecar:get-catalog',
25
24
  'sidecar:refresh-catalog',
26
25
  'sidecar:fetch-free-models'
@@ -38,23 +38,77 @@ function buildCouncilScript() {
38
38
  else if (meta && !loaded) { meta.textContent = ''; }
39
39
  };
40
40
 
41
+ // Rows arrive pre-sorted by vendor (listFreeModels); group consecutive
42
+ // same-vendor runs into collapsible sections, mirroring the Step-3
43
+ // alias editor's .alias-group <details>/<summary> precedent.
44
+ function groupByVendor(rows) {
45
+ var groups = [];
46
+ var byVendor = {};
47
+ (rows || []).forEach(function(r) {
48
+ var v = r.vendor || (r.id.split('/')[1] || '');
49
+ if (!byVendor[v]) {
50
+ byVendor[v] = { vendor: v, rows: [] };
51
+ groups.push(byVendor[v]);
52
+ }
53
+ byVendor[v].rows.push(r);
54
+ });
55
+ return groups;
56
+ }
57
+
58
+ function vendorLabel(vendor) {
59
+ if (!vendor) { return 'Other'; }
60
+ return vendor.charAt(0).toUpperCase() + vendor.slice(1);
61
+ }
62
+
63
+ function buildRow(r, idx) {
64
+ var id = 'fc-' + idx;
65
+ var row = document.createElement('label');
66
+ row.className = 'council-row';
67
+ var cb = document.createElement('input');
68
+ cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
69
+ var text = document.createElement('span');
70
+ text.className = 'council-row-text';
71
+ var nameEl = document.createElement('span');
72
+ nameEl.className = 'council-row-name'; nameEl.textContent = r.name || r.id;
73
+ var idEl = document.createElement('span');
74
+ idEl.className = 'council-row-id'; idEl.textContent = r.id;
75
+ text.appendChild(nameEl); text.appendChild(idEl);
76
+ row.appendChild(cb); row.appendChild(text);
77
+ return row;
78
+ }
79
+
80
+ function buildGroup(group, startIdx) {
81
+ var details = document.createElement('details');
82
+ details.className = 'council-group';
83
+ details.open = true;
84
+ var summary = document.createElement('summary');
85
+ var summaryLabel = document.createElement('span');
86
+ summaryLabel.textContent = vendorLabel(group.vendor);
87
+ var count = document.createElement('span');
88
+ count.className = 'council-group-count';
89
+ count.textContent = '(' + group.rows.length + ')';
90
+ summary.appendChild(summaryLabel); summary.appendChild(count);
91
+ details.appendChild(summary);
92
+ group.rows.forEach(function(r, i) { details.appendChild(buildRow(r, startIdx + i)); });
93
+ return details;
94
+ }
95
+
41
96
  async function loadFree() {
42
97
  if (loaded) { return; }
43
98
  try {
44
99
  var rows = await window.sidecarSetup.invoke('sidecar:fetch-free-models');
45
100
  loaded = true;
46
101
  results.innerHTML = '';
47
- (rows || []).forEach(function(r, i) {
48
- var id = 'fc-' + i;
49
- var row = document.createElement('label');
50
- row.className = 'council-row';
51
- var cb = document.createElement('input');
52
- cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
53
- var span = document.createElement('span'); span.textContent = r.id;
54
- row.appendChild(cb); row.appendChild(span);
55
- results.appendChild(row);
102
+ var groups = groupByVendor(rows);
103
+ var idx = 0;
104
+ groups.forEach(function(group) {
105
+ results.appendChild(buildGroup(group, idx));
106
+ idx += group.rows.length;
56
107
  });
57
- if (meta) { meta.textContent = (rows || []).length + ' free models'; }
108
+ if (meta) {
109
+ meta.textContent = (rows || []).length + ' free models across ' + groups.length +
110
+ ' provider' + (groups.length === 1 ? '' : 's');
111
+ }
58
112
  } catch (_e) { if (meta) { meta.textContent = 'Could not load free models.'; } }
59
113
  }
60
114
 
@@ -357,9 +357,40 @@ function __rawWizardCSS() {
357
357
  /* Free council picker (Step 2) */
358
358
  .council-section { margin-top: 14px; }
359
359
  .council-toggle { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
360
- .council-results { max-height: 160px; overflow-y: auto; margin-top: 6px; }
361
- .council-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px; color: var(--text); font-family: var(--font-mono); }
362
- .council-row input[type="checkbox"] { accent-color: var(--accent); }
360
+ .council-results {
361
+ max-height: 220px; overflow-y: auto; margin-top: 6px;
362
+ border: 1px solid var(--border); border-radius: var(--r-6);
363
+ }
364
+ .council-results:empty { border: none; }
365
+ .council-group { margin: 0; border-bottom: 1px solid var(--border); }
366
+ .council-group:last-child { border-bottom: none; }
367
+ .council-group summary {
368
+ display: flex; align-items: center; gap: 6px; padding: 6px 10px;
369
+ cursor: pointer; font-size: 12px; font-weight: 500; color: var(--text-muted);
370
+ list-style: none; transition: color var(--dur-fast);
371
+ }
372
+ .council-group summary::-webkit-details-marker { display: none; }
373
+ .council-group summary::before {
374
+ content: '\\25B6'; font-size: 8px; color: var(--text-faint); transition: transform var(--dur-fast);
375
+ }
376
+ .council-group[open] summary::before { transform: rotate(90deg); }
377
+ .council-group summary:hover { color: var(--accent); }
378
+ .council-group-count { color: var(--text-faint); font-weight: 400; }
379
+ .council-row {
380
+ display: flex; align-items: center; gap: 8px;
381
+ padding: 5px 10px 5px 24px; font-size: 12px; color: var(--text); cursor: pointer;
382
+ }
383
+ .council-row:hover { background: var(--surface-hover); }
384
+ .council-row input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
385
+ .council-row-text { display: flex; flex-direction: column; min-width: 0; }
386
+ .council-row-name {
387
+ color: var(--text); font-size: 12px; overflow: hidden;
388
+ text-overflow: ellipsis; white-space: nowrap;
389
+ }
390
+ .council-row-id {
391
+ color: var(--text-muted); font-size: 11px; font-family: var(--font-mono);
392
+ margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
393
+ }
363
394
  .council-note { font-size: 10px; color: var(--text-faint); margin-top: 6px; line-height: 1.4; }
364
395
 
365
396
  /* Alias example-icon strokes — driven by class rules (var() is invalid as an SVG attribute) */
@@ -9,6 +9,7 @@ const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-coun
9
9
  const { getDefaultAliases } = require('../src/utils/config');
10
10
  const { getBrandName } = require('./toolbar');
11
11
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
12
+ const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
12
13
 
13
14
  /**
14
15
  * @param {object} [options={}]
@@ -30,6 +31,7 @@ function buildSetupHTML(options = {}) {
30
31
  const modelChoicesJson = JSON.stringify(quickPicks);
31
32
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
32
33
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
34
+ const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
33
35
  return `<!DOCTYPE html>
34
36
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
35
37
  <style>${css}</style></head><body>
@@ -51,11 +53,11 @@ function buildSetupHTML(options = {}) {
51
53
  </div>
52
54
  </div>
53
55
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
54
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson)}
56
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
55
57
  </body></html>`;
56
58
  }
57
59
 
58
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson) {
60
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
59
61
  const keysJs = buildKeysScript();
60
62
  const aliasJs = buildAliasScript();
61
63
  const councilJs = buildCouncilScript();
@@ -69,6 +71,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
69
71
  var modelChoicesData = ${modelChoicesJson};
70
72
  var providerNamesData = ${providerNamesJson};
71
73
  var defaultAliases = ${defaultAliasesJson};
74
+ var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
72
75
  var routingChoices = {};
73
76
  var aliasEdits = {};
74
77
  var aliasDisplay = {};
@@ -178,7 +181,10 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
178
181
  if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); window.refreshCouncilGating && window.refreshCouncilGating(); }
179
182
  if (step === 3) {
180
183
  updateAliasRoutes();
181
- if (!window.availableModels) { fetchAvailableModels(); }
184
+ // B33 / #12: Step 3 shares Step 2's TTL-cached catalog load (single
185
+ // in-page cache: ensureCatalogLoaded no-ops if Step 2 already loaded
186
+ // it) instead of a separate live sidecar:fetch-models round-trip.
187
+ ensureCatalogLoaded();
182
188
  }
183
189
  updateNextState();
184
190
  }
@@ -393,15 +399,11 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
393
399
  } catch (_e) { finishBtn.disabled = false; finishBtn.textContent = 'Finish'; }
394
400
  });
395
401
 
396
- async function fetchAvailableModels() {
397
- try {
398
- var groups = await window.sidecarSetup.invoke('sidecar:fetch-models');
399
- if (groups && groups.length > 0) { window.availableModels = groups; }
400
- } catch (_e) {}
401
- }
402
-
403
- // ===== F5: searchable catalog picker (Step 2) =====
402
+ // ===== F5: searchable catalog picker (Step 2) + B33/#12: shared with Step 3 =====
404
403
  var catalogRows = null, catalogFetchedAt = null;
404
+ // #13: last-refresh outcome, so a stale cache (refresh keeps failing) is
405
+ // shown honestly instead of looking current.
406
+ var catalogLastRefreshAttempt = null, catalogLastRefreshError = null;
405
407
  window.customDefaultModel = null;
406
408
 
407
409
  async function ensureCatalogLoaded() {
@@ -412,9 +414,32 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
412
414
  } catch (_e) {}
413
415
  }
414
416
 
417
+ // Re-derive Step 3's grouped {family, models} shape from the flat catalog
418
+ // rows client-side (mirrors src/utils/model-fetcher.js groupModelsByFamily
419
+ // keying: family name from the id prefix, falling back to the prefix
420
+ // itself for any provider not in PROVIDER_FAMILY_NAMES).
421
+ function groupCatalogByFamily(rows) {
422
+ if (!rows || rows.length === 0) { return []; }
423
+ var order = [], byFamily = {};
424
+ rows.forEach(function(m) {
425
+ var prefix = m.id.split('/')[0];
426
+ var family = PROVIDER_FAMILY_NAMES[prefix] || prefix;
427
+ if (!byFamily[family]) { byFamily[family] = []; order.push(family); }
428
+ byFamily[family].push(m);
429
+ });
430
+ return order.map(function(family) { return { family: family, models: byFamily[family] }; });
431
+ }
432
+
415
433
  function applyCatalog(info) {
416
434
  catalogRows = (info && info.models) || [];
417
435
  catalogFetchedAt = info && info.fetchedAt;
436
+ catalogLastRefreshAttempt = info && info.lastRefreshAttempt;
437
+ catalogLastRefreshError = info && info.lastRefreshError;
438
+ // Single shared in-page cache: Step 3's alias dropdown (buildModelSelect)
439
+ // reads window.availableModels, re-derived from the same catalog load
440
+ // Step 2 uses — no second get-catalog round-trip, and the refresh
441
+ // button (Step 2) re-applying here keeps Step 3's dropdown data current.
442
+ window.availableModels = groupCatalogByFamily(catalogRows);
418
443
  renderSearchMeta();
419
444
  renderSearchResults();
420
445
  if (catalogRows.length === 0) {
@@ -427,7 +452,14 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
427
452
  var meta = $('model-search-meta');
428
453
  if (!meta) { return; }
429
454
  var when = catalogFetchedAt ? new Date(catalogFetchedAt).toLocaleString() : 'never';
430
- meta.textContent = catalogRows.length + ' models \\u00b7 catalog fetched ' + when;
455
+ var text = catalogRows.length + ' models \\u00b7 catalog fetched ' + when;
456
+ // #13: one-line stale hint when the last refresh attempt failed AFTER
457
+ // the data currently shown was fetched (don't redesign Step 2 for this).
458
+ if (catalogLastRefreshError && catalogLastRefreshAttempt &&
459
+ (!catalogFetchedAt || catalogLastRefreshAttempt > catalogFetchedAt)) {
460
+ text += ' \\u2014 \\u26a0 refresh failed, showing last-known data';
461
+ }
462
+ meta.textContent = text;
431
463
  }
432
464
 
433
465
  function fmtCtx(n) { return n == null ? '' : ' \\u00b7 ctx ' + n; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.9.0",
3
+ "version": "2.0.0",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -28,9 +28,7 @@
28
28
  "homepage": "https://bourbondog.github.io/amicus/",
29
29
  "bin": {
30
30
  "amicus": "./bin/amicus.js",
31
- "am": "./bin/amicus.js",
32
- "sidecar": "./bin/amicus.js",
33
- "claude-sidecar": "./bin/amicus.js"
31
+ "am": "./bin/amicus.js"
34
32
  },
35
33
  "main": "src/index.js",
36
34
  "exports": {
@@ -85,7 +83,6 @@
85
83
  "devDependencies": {
86
84
  "chrome-remote-interface": "^0.33.3",
87
85
  "eslint": "^8.0.0",
88
- "husky": "^9.1.7",
89
86
  "jest": "^29.0.0",
90
87
  "lint-staged": "^16.3.2",
91
88
  "puppeteer": "^24.36.0",
@@ -25,8 +25,8 @@ injection, long-read failures, judge tool-wander; see changelog)._
25
25
  - **Transient provider errors** (502s, connection drops): re-run the affected leg (solo
26
26
  `amicus start --json`, same briefing file) or the wave — see per-model notes for
27
27
  model-specific signals. Never present a half-finished run as an answer.
28
- - **Credentials:** keys live in `~/.config/amicus/.env` (legacy `~/.config/sidecar/.env` still
29
- read). Configure with `amicus setup`.
28
+ - **Credentials:** keys live in `~/.config/amicus/.env`. The legacy `~/.config/sidecar/.env`
29
+ fallback was removed in v2.0.0 (see `docs/SHIMS.md`). Configure with `amicus setup`.
30
30
  - **PowerShell `--models` quoting (Windows):** always quote comma-separated model lists —
31
31
  `--models "gemini,gpt,deepseek"`. Unquoted, PowerShell splits on commas and amicus receives one
32
32
  mangled alias → instant arg-parse failure. (Now baked into every SKILL.md example.)
@@ -165,16 +165,16 @@ equivalent.
165
165
 
166
166
  Instruct models to emit the structured JSON verbatim after the prose, without preamble, so it parses cleanly.
167
167
 
168
- **After the wave returns, validate each leg's findings block** using `validateFindings` (Unit A `src/council/findings.js`). If a leg's JSON fails validation:
168
+ Save each leg's full output (prose + findings block) to the run folder as `review-<model>.md`
169
+ (one file per reviewer) before moving on.
170
+
171
+ **After the wave returns, validate each leg's findings block** by running `amicus council validate <leg-file> --json` (a thin CLI wrapper over `validateFindings`, Unit A — `src/council/findings.js`). It reads the leg's saved `review-<model>.md` and prints `{ok, findings, errors}`. Exit codes are a **tri-state** contract: `0` when `ok:true` (well-formed, proceed), `2` when `ok:false` (validation failed — a distinct, scriptable outcome, not a crash), `1` (`BAD_ARGS`) for a missing/unreadable file. If a leg's JSON fails validation (`ok:false` / exit 2):
169
172
  1. Issue a **solo `start --json`** re-prompt to that one model: "re-emit only the findings JSON, fixing: \<errors\>." Keep the first-pass prose. (Solo `start` passes through the **same budget gate** as `fanout`. If launching the wave required `--max-cost <$>` or `--no-cost-gate`, pass the **same flag on every repair re-prompt and on the chair call** — otherwise the gate can refuse a repair or the chair mid-council.)
170
173
  2. If still malformed, retry **once more** (cap = **2** re-prompts total).
171
174
  3. If still malformed after 2 retries, mark the review `unstructured` and hand-parse its prose into the schema. The review proceeds — never dropped for a formatting miss.
172
175
 
173
176
  Record per-model **conformance** (`clean` | `repaired` | `unstructured`) for inclusion in the tally input's `runStats` and the Stage-6 MODEL-NOTES note.
174
177
 
175
- Save each leg's full output (prose + findings block) to the run folder as `review-<model>.md`
176
- (one file per reviewer) before moving on.
177
-
178
178
  **"Claude in the council" (when toggled on):** Claude also produces a **fresh** Stage-1 review on the artifact in the identical findings format — a new structured pass on the artifact, not a formalization of anything said upstream. This review is added to the bundle as one more anonymous entry. Claude does not rank or adjudicate in Stage 2 (it holds the label map), and does not chair in Stage 3. Save it as `review-claude.md`.
179
179
 
180
180
  **Wave-degrade rules (Stage 1).** Read failures from the wave document — never silently ignore
@@ -210,7 +210,7 @@ amicus fanout --models "<m1,m2,m3>" --prompt-file <run-folder>/_tmp-bundle-stage
210
210
  (Background, same JSON handling as Stage 1.) Each judge's leg `summary` is its ranking +
211
211
  adjudication response. **Stage-2 degrade:** a judge leg dies → tally over the surviving judges
212
212
  (≥ 1) and disclose the reduced bench in `crossreview-matrix.md`; tier definitions are unchanged
213
- (they already count "judges engaged"). Each judge is asked to do two things on the bundle:
213
+ (they already count "judges engaged").
214
214
 
215
215
  **Judge-briefing hardening (required).** Open `_tmp-bundle-stage2.md` with this preamble, verbatim, as its first line:
216
216
 
@@ -218,6 +218,8 @@ adjudication response. **Stage-2 degrade:** a judge leg dies → tally over the
218
218
 
219
219
  Plan-agent judges have wandered to tools mid-adjudication (reading files instead of judging and returning only narration), and a tool-capable judge can read the de-anonymized `review-<model>.md` files in the run folder — an anonymization leak. The preamble closes both. **Scratch-cwd (optional second layer):** launch the Stage-2 wave (and the Stage-3 chair call) with `--cwd <run-folder>/_scratch/` — create the empty directory first — so even a wandering agent finds nothing to read. Caveat: those legs' session records then live under `_scratch/.claude/amicus_sessions/`, so any later `amicus read <taskId>` for them needs the same `--cwd`.
220
220
 
221
+ Each judge is asked to do two things on the bundle:
222
+
221
223
  **Task A — Rank.** Order the reviews from most to least accurate and insightful. End the response with a parseable block in exactly this format (no other text on those lines):
222
224
 
223
225
  ```
@@ -242,12 +244,14 @@ As each judge's ranking + adjudication response returns, collect it (the raw per
242
244
 
243
245
  **Five-keys checklist — verify `tally-input.json` has ALL of:** `meta` (with `meta.models`), `findings`, `adjudications`, `rankings`, `runStats` (`runStats` may be `[]`; the other four are required). Do not call `tally` until all five are present.
244
246
 
245
- Then call:
247
+ Then call, saving the printed `record` to `<run-folder>/tally.json` (Stage 5's `amicus council verdict` reads it back from disk):
246
248
 
247
249
  ```
248
- amicus council tally <run-folder>/tally-input.json --json
250
+ amicus council tally <run-folder>/tally-input.json --json > <run-folder>/tally.json
249
251
  ```
250
252
 
253
+ **Windows PowerShell 5.1 caveat:** that `>` redirect writes UTF-16 under legacy Windows PowerShell 5.1 (fine on pwsh 7+ or bash), which corrupts `tally.json` for Stage 5's `amicus council verdict` and surfaces as a confusing `BAD_ARGS` there instead of here — on 5.1 pipe through `| Out-File -Encoding utf8` (or run under pwsh 7+) instead of a bare `>`.
254
+
251
255
  The output `record` carries the deterministic tiers (Disputed / Confirmed / Contested / Singleton), `confidence` (`solid` | `thin`), both street-cred numbers (`withSelf` and `peersOnly`), the validated `runStats`, and `tierCounts`. **Claude may override a `thin`-confidence tier at the margins** before Stage 4 — record the override in `tierOverride: {from, to, reason}`; the matrix and `verdict.json` surface it. De-anonymize and write the tally results to `crossreview-matrix.md` — the adjudication grid plus the street-cred table. This data feeds Stage 3 (chair briefing) and is never re-anonymized or forwarded to any council model.
252
256
 
253
257
  ---
@@ -334,7 +338,7 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
334
338
  - `review-<model>.md` × N (already saved in Stage 1)
335
339
  - `crossreview-matrix.md` — the de-anonymized adjudication grid and street-cred table
336
340
  - `verdict.md` (already saved in Stage 3)
337
- - `verdict.json` — write via `buildVerdict(record, decisions)` (`src/council/verdict.js`): pass the tally `record` from Stage 2 and the Stage-4 decision map (accepted / denied / modified / deferred per finding, plus any `duplicateOf` links Claude identified). This is the schema-stamped machine-readable record of the full run. Write it with an atomic tmp+rename to the run folder.
341
+ - `verdict.json` — write by running `amicus council verdict <run-folder>/tally.json --decisions <run-folder>/decisions.json -o <run-folder>/verdict.json` (a thin CLI wrapper over `buildVerdict(record, decisions)` + `writeVerdictAtomic`, `src/council/verdict.js`). `<run-folder>/tally.json` is the `record` saved from the Stage-2 `amicus council tally` call. `<run-folder>/decisions.json` is a **JSON array**, one object per finding: `{id, decision, applied?, duplicateOf?, tierOverride?}` — `id` is the run-global label id (e.g. `A1`); `decision` is the Stage-4 outcome (accepted / denied / modified / deferred); `applied` (optional bool) marks whether the accepted change was actually applied to the artifact in Stage 5; `duplicateOf` (optional) links to another finding's id when Claude identified a duplicate; `tierOverride` (optional) carries any `{from, to, reason}` override recorded in Stage 2. Save this array to `<run-folder>/decisions.json` first, then run the command — it parses the tally record and the decisions file, calls `buildVerdict`, and writes the schema-stamped machine-readable record to the run folder via the same atomic tmp+rename convention the function always used.
338
342
  - `report.md` — the chair's synthesis + the full Stage-4 decision log + a summary of what was
339
343
  applied (+ the "How Claude's review fared" readout when "Claude in the council" is on) + a
340
344
  **run-stats table**: one row per model call — **stage** (which stage you launched the call for)
@@ -343,15 +347,17 @@ Do not advance to Stage 5 until every finding in both tiers has a recorded decis
343
347
  — exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
344
348
  invent a figure. Add a wave **total cost** row from the wave document's
345
349
  `usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`; never invent a value.
346
- - **Renderer:** once `verdict.json` is written, generate BOTH renderings:
347
- `amicus council report <run-folder>/verdict.json --md > <run-folder>/report.md` and
348
- `amicus council report <run-folder>/verdict.json --html > <run-folder>/report.html`.
349
- **`report.html` is the default final artifact to hand the user** — a self-contained,
350
- shareable page. This emits the
350
+ - **Renderer:** once `verdict.json` is written, run
351
+ `amicus council report <run-folder>/verdict.json --html > <run-folder>/report.html` — a
352
+ **separate, deterministic** artifact, not report.md itself. **`report.html` is the default
353
+ final artifact to hand the user** — a self-contained, shareable page. This emits the
351
354
  adjudication matrix (finding × judge), the peers-only street-cred table, the
352
355
  findings-by-tier groupings (Disputed-first), and the per-model + wave cost —
353
- deterministic data only. Prefer it over hand-assembling the matrix; reserve
354
- prose for the chair's synthesis and the decision log.
356
+ deterministic data only. To assemble report.md, also run
357
+ `amicus council report <run-folder>/verdict.json --md` (no redirect read its stdout) and
358
+ paste that Markdown into report.md as one section; reserve the rest of report.md's prose for
359
+ the chair's synthesis and the decision log. Prefer the renderer's Markdown over
360
+ hand-assembling the matrix by hand.
355
361
 
356
362
  Tell the user exactly which files were written and where, leading with `report.html`, **and present the verdict inline in chat** — the chair's overall assessment (verbatim or lightly trimmed) plus the tier counts (Confirmed/Disputed/Contested/Singleton) and what was applied. Never hand over only file paths.
357
363
 
@@ -465,14 +471,9 @@ Always **rank recommendations by fit**, state the trade-off for each option, and
465
471
  - `review-<model>.md` ×N — raw Stage 1 reviews (plus `review-claude.md` when "Claude in the council" is on)
466
472
  - `crossreview-matrix.md` — adjudication grid + de-anonymized street-cred table
467
473
  - `verdict.md` — the chair's synthesis (prose)
468
- - `verdict.json` — schema-stamped machine-readable record: tally output + Stage-4 decisions, written via `buildVerdict(record, decisions)` at Stage 5
469
- - `report.md` — synthesis + decision log + what was applied (+ the "How Claude's review fared" readout when the toggle is on) + a
470
- **run-stats table**: one row per model call **stage** (which stage you launched the call for) plus **model, status, durationMs, and cost** read from the wave/run JSON `usage`
471
- block. Cost is `usage.cost.amount` (USD); mark it with its `usage.cost.source`
472
- — exact for `reported`, `~` for `estimated`, `?` for `unknown` — and never
473
- invent a figure. Add a wave **total cost** row from the wave document's
474
- `usage.cost` (`source: reported|estimated|mixed|unknown`). Any leg with no run doc → `durationMs: null`, `usage: null`.
475
- - `report.html` — the same report rendered as a self-contained page (`amicus council report <verdict.json> --html`); the default artifact to share.
474
+ - `verdict.json` — schema-stamped machine-readable record: tally output + Stage-4 decisions, written via `amicus council verdict` at Stage 5
475
+ - `report.md` — Claude-authored; full contract defined once in *Stage 5 Run-folder artifacts* above (chair's synthesis + Stage-4 decision log + run-stats table).
476
+ - `report.html` a **separate, deterministic** artifact generated by the `amicus council report <verdict.json> --html` renderer directly from `verdict.json` (no chair prose, no decision-log narrative see Stage 5's *Renderer* note); the default artifact to share.
476
477
  - Reviewed copy: `<stem>-reviewed.<ext>`, next to the source.
477
478
  - Temp working files (`_tmp-*.md`: extracts, stage briefings, red-team brief, bundle, chair packet, proposed
478
479
  MODEL-NOTES diff) live in the run folder and are cleaned up at the end of the run — the proposed-diff file
@@ -747,13 +747,13 @@ The summary includes:
747
747
 
748
748
  ### Handling Config Update Notices
749
749
 
750
- When amicus emits `[AMICUS_CONFIG_UPDATE]` on stderr (or the legacy `[SIDECAR_CONFIG_UPDATE]` from older installs — accept either), the user's model aliases have changed (via `amicus setup`). The message contains a hash comment and a markdown alias table.
750
+ When amicus emits `[AMICUS_CONFIG_UPDATE]` on stderr, the user's model aliases have changed (via `amicus setup`). The message contains a hash comment and a markdown alias table.
751
751
 
752
752
  **Action required:**
753
753
  1. Find the "Model Aliases" section in the project's CLAUDE.md
754
754
  2. Replace that section's content with the block provided in the message
755
755
  3. If no "Model Aliases" section exists, add one after the title line
756
- 4. Do **not** remove the `<!-- amicus-config-hash: ... -->` comment (legacy files may use `<!-- sidecar-config-hash: ... -->` do not remove whichever is present) — it tracks config changes and prevents duplicate notifications
756
+ 4. Do **not** remove the `<!-- amicus-config-hash: ... -->` comment — it tracks config changes and prevents duplicate notifications
757
757
 
758
758
  The update is purely informational (alias mappings) and safe to apply without user confirmation.
759
759
 
@@ -846,7 +846,7 @@ The mutex approach looks correct. Add tests."
846
846
 
847
847
  ### "Missing Authentication header" in Claude Code or CI
848
848
 
849
- API keys in `~/.zshrc` are not available in non-interactive shells. Resolution order: `process.env` > `~/.config/amicus/.env` (legacy `~/.config/sidecar/.env` still read) > `~/.local/share/opencode/auth.json` (first wins). Fix:
849
+ API keys in `~/.zshrc` are not available in non-interactive shells. Resolution order: `process.env` > `~/.config/amicus/.env` (the legacy `~/.config/sidecar/.env` fallback was removed in v2.0.0 — see `docs/SHIMS.md`) > `~/.local/share/opencode/auth.json` (first wins). Fix:
850
850
  1. Run `amicus setup` (stores keys in `~/.config/amicus/.env`)
851
851
  2. Or move exports to `~/.zshenv`
852
852
  3. Or add credentials to `~/.local/share/opencode/auth.json`