amicus 4.8.0 → 4.8.1

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.
@@ -20,15 +20,19 @@ const { listDirectProviders } = require('../src/utils/provider-registry');
20
20
  * @param {string} [options.client='code-local'] - Client type for branding
21
21
  * @param {Array} [options.quickPicks] - Resolved quick-pick rows from resolveQuickPicks(catalog).
22
22
  * Defaults to pinned fallbacks when not provided.
23
+ * @param {Object<string,object>} [options.shortlists] - issue 138: per-alias vendor
24
+ * shortlist from buildModelShortlist(), passed through to buildModelStepHTML
25
+ * for the model-level <select>. Defaults to {} (no drill-down rendered).
23
26
  */
24
27
  function buildSetupHTML(options = {}) {
25
28
  const {
26
29
  client = 'code-local',
27
30
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
31
+ shortlists = {},
28
32
  } = options;
29
33
  const brandName = getBrandName(client);
30
34
  const keysHtml = buildKeysStepHTML(PROVIDERS);
31
- const modelHtml = buildModelStepHTML(quickPicks);
35
+ const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
32
36
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
33
37
  const css = buildWizardCSS();
34
38
  const providersJson = JSON.stringify(PROVIDERS);
@@ -82,8 +86,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
82
86
  var directProviders = ${directProvidersJson};
83
87
  var routingChoices = {};
84
88
  var explicitRouteChoices = {};
89
+ // issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
90
+ // means "use the family flagship", i.e. today's behavior.
91
+ var modelChoiceIds = {};
92
+ var modelOpenrouterIds = {};
85
93
  var aliasEdits = {};
86
94
  var aliasDisplay = {};
95
+ // N1: the alias map as loaded from disk (init below), so buildReview can
96
+ // tell an actual change from a value-identical re-write. Stays {} until
97
+ // init resolves (a fresh/offline config has nothing saved yet, so every
98
+ // computed write IS new).
99
+ var savedAliases = {};
87
100
  window.availableModels = null;
88
101
  var keyValid = false, validatedKey = '';
89
102
  var $ = function(id) { return document.getElementById(id); };
@@ -141,6 +154,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
141
154
  window.customDefaultModel = cfg.default;
142
155
  }
143
156
  if (cfg && cfg.aliases) {
157
+ // N-a (council review, PR 196): a defensive copy, not the live cfg.aliases
158
+ // reference. Nothing in this file currently mutates cfg.aliases after
159
+ // this point (checked: every '.aliases[' site below is a read), so
160
+ // buildReview's N1 diff is not measured to be wrong today -- but the
161
+ // copy is one line and removes a fragile "never mutate this" invariant
162
+ // future edits would otherwise have to remember.
163
+ savedAliases = Object.assign({}, cfg.aliases); // N1: buildReview diffs against this
144
164
  modelChoicesData.forEach(function(mc) {
145
165
  var currentModel = cfg.aliases[mc.alias];
146
166
  if (currentModel) {
@@ -148,6 +168,62 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
148
168
  for (var i = 0; i < provs.length; i++) {
149
169
  if (mc.routes[provs[i]] === currentModel) { routingChoices[mc.alias] = provs[i]; break; }
150
170
  }
171
+ // F3: the shortlist <select> is server-rendered with its OWN
172
+ // recommendedId-derived "selected" option and never consults
173
+ // modelChoiceIds at init -- so a saved drill-down pick silently
174
+ // reverted to the family flagship on every reopen (Step 3's
175
+ // alias table showed the saved value while Step 2 showed a
176
+ // different one). Mirror the customDefaultModel restore just
177
+ // above: when the saved alias value names one of THIS card's
178
+ // shortlist rows, seed modelChoiceIds (the .model-pick change
179
+ // handler's own state) and push the same value into the DOM
180
+ // <select> so a no-op reopen-and-Finish round-trips cleanly.
181
+ var sel = document.querySelector('.model-pick[data-alias="' + mc.alias + '"]');
182
+ if (sel) {
183
+ var matchedByValue = false;
184
+ for (var j = 0; j < sel.options.length; j++) {
185
+ if (sel.options[j].value === currentModel) {
186
+ sel.value = currentModel;
187
+ modelChoiceIds[mc.alias] = currentModel;
188
+ modelOpenrouterIds[mc.alias] = sel.options[j].getAttribute('data-or') || null;
189
+ matchedByValue = true;
190
+ break;
191
+ }
192
+ }
193
+ // F3-OR (council review, issue 138): an explicit-OpenRouter
194
+ // drilled pick is saved as "openrouter/<vendor>/<model>"
195
+ // (pickRouteFor's explicit-OR branch below) -- that form
196
+ // matches no option's bare 'value', only its 'data-or'. Every
197
+ // option carries 'data-or' (CONTROLLER RULING R1, issue 138 --
198
+ // see buildModelPickHTML's docstring in setup-ui-model.js), so
199
+ // search for THAT match when the bare-value search above
200
+ // found nothing.
201
+ // Restoring modelChoiceIds alone here would round-trip the
202
+ // dropdown selection but make Finish write the BARE id back --
203
+ // silently converting the user's explicit "via OpenRouter"
204
+ // choice into direct-first policy routing on the very next
205
+ // save, which is worse than the visible flagship-revert this
206
+ // block exists to fix. So restore the WHOLE state pickRouteFor
207
+ // needs to reproduce the OR form: the option's bare value (for
208
+ // the <select> and modelChoiceIds), its data-or
209
+ // (modelOpenrouterIds), and the two flags pickRouteFor's
210
+ // explicit-OR branch checks -- routingChoices[alias] ===
211
+ // 'openrouter' and explicitRouteChoices[alias] -- which are
212
+ // exactly the flags the route-pill click handler sets, so the
213
+ // rendered pill agrees with the restored state too.
214
+ if (!matchedByValue) {
215
+ for (var k = 0; k < sel.options.length; k++) {
216
+ if (sel.options[k].getAttribute('data-or') === currentModel) {
217
+ sel.value = sel.options[k].value;
218
+ modelChoiceIds[mc.alias] = sel.options[k].value;
219
+ modelOpenrouterIds[mc.alias] = currentModel;
220
+ routingChoices[mc.alias] = 'openrouter';
221
+ explicitRouteChoices[mc.alias] = true;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ }
151
227
  }
152
228
  });
153
229
  Object.keys(cfg.aliases).forEach(function(k) {
@@ -224,6 +300,20 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
224
300
  // the row's first route. Returns the full model id or null.
225
301
  function pickRouteFor(mc) {
226
302
  if (!mc) { return null; }
303
+ // issue 138: an explicit per-model choice overrides the family flagship.
304
+ var picked = modelChoiceIds[mc.alias];
305
+ if (picked) {
306
+ // Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
307
+ // canonicalization below): picked/modelOpenrouterIds come straight
308
+ // from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
309
+ // (e.g. anthropic) that id can already BE its only-callable
310
+ // openrouter/<vendor>/... form -- stripping the prefix would
311
+ // fabricate a direct id nothing serves.
312
+ if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
313
+ return modelOpenrouterIds[mc.alias] || picked;
314
+ }
315
+ return picked;
316
+ }
227
317
  var provs = Object.keys(mc.routes);
228
318
  var prov = routingChoices[mc.alias];
229
319
  if (!prov || !mc.routes[prov]) {
@@ -363,21 +453,52 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
363
453
  kn.length > 0 ? kn.map(function(k) { return k + ' \\u2713'; }).join(', ') : 'None';
364
454
  var r = document.querySelector('input[name="default-model"]:checked');
365
455
  document.getElementById('review-model').textContent = window.customDefaultModel || (r ? r.value : 'Not selected');
366
- var writes = [];
367
- var r2 = document.querySelector('input[name="default-model"]:checked');
368
- if (!window.customDefaultModel && r2) {
369
- var mc2 = null;
370
- for (var i2 = 0; i2 < modelChoicesData.length; i2++) {
371
- if (modelChoicesData[i2].alias === r2.value) { mc2 = modelChoicesData[i2]; break; }
456
+ // F4: mirror the EXACT call Finish makes (collectAliasWrites), not just
457
+ // the checked radio, so Step 4 can never under-report what Finish is
458
+ // about to write. Before this fix, buildReview re-derived "writes" from
459
+ // the checked radio alone -- a drilled-down pick on a card that was NOT
460
+ // the checked default (or any drilled pick at all, under a custom
461
+ // default) was invisible here and fell through to the literal 'No
462
+ // alias changes', even though Finish wrote it. sidecar:save-config has
463
+ // no confirmation step, so this review IS the only gate.
464
+ var aliasWritesPreview = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
465
+ // N1: aliasWritesPreview is EVERY alias Finish would write, including
466
+ // ones whose value is already what's on disk -- after the F1 fix that
467
+ // is the NORMAL case on a plain reopen (init below seeds modelChoiceIds
468
+ // from cfg.aliases, so the drilled-down alias, the recommendedId, and
469
+ // the saved value are now the same string by construction). Finish
470
+ // still writes the full map (a value-identical write is harmless), but
471
+ // this review must only SHOW entries that actually differ from
472
+ // savedAliases -- otherwise a no-op reopen reports "N alias(es)
473
+ // modified" on the one screen that has no confirmation step after it.
474
+ // N-b (council review, PR 196): an alias that was NEVER in savedAliases
475
+ // reads as undefined there, while a delete-write (Step 3's delete
476
+ // button, for one of the five default aliases -- see aliasEdits[alias]
477
+ // = null in setup-ui-alias-script.js) is null. null !== undefined is
478
+ // true, so without this normalization a default alias that was never
479
+ // explicitly saved (a config written by an older/partial flow that
480
+ // skipped default-seeding -- addAlias() in src/sidecar/setup.js is one
481
+ // such path) would show "alias -> (deleted)" for deleting something
482
+ // that was never there. saveConfig() already drops falsy alias values
483
+ // (src/utils/config.js), so that write is a true no-op on disk; the
484
+ // review must agree. Reading savedAliases[alias] as null (not
485
+ // undefined) when the key is absent makes "delete of an absent alias"
486
+ // compare equal to "still absent" without changing any other case --
487
+ // every other savedAliases value here is a non-empty string.
488
+ var changedWrites = {};
489
+ Object.keys(aliasWritesPreview).forEach(function(alias) {
490
+ var oldVal = Object.prototype.hasOwnProperty.call(savedAliases, alias) ? savedAliases[alias] : null;
491
+ if (aliasWritesPreview[alias] !== oldVal) {
492
+ changedWrites[alias] = aliasWritesPreview[alias];
372
493
  }
373
- if (mc2) {
374
- var routeId2 = pickRouteFor(mc2);
375
- if (routeId2) { writes.push(mc2.alias + ' \\u2192 ' + routeId2); }
376
- }
377
- }
494
+ });
495
+ var writes = Object.keys(changedWrites).map(function(alias) {
496
+ var val = changedWrites[alias];
497
+ return alias + ' \\u2192 ' + (val === null ? '(deleted)' : val);
498
+ });
378
499
  document.getElementById('review-routing').textContent =
379
500
  writes.length > 0 ? writes.join(', ') : 'No alias changes';
380
- var editCount = Object.keys(aliasEdits).length;
501
+ var editCount = Object.keys(changedWrites).length;
381
502
  var reviewAliases = document.getElementById('review-aliases');
382
503
  if (reviewAliases) {
383
504
  reviewAliases.textContent = editCount > 0 ? editCount + ' alias(es) modified' : 'No changes';
@@ -401,27 +522,97 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
401
522
  updateWritePreviews();
402
523
  });
403
524
 
525
+ // issue 138: model-level drill-down <select> change handler.
526
+ document.addEventListener('change', function(e) {
527
+ var sel = e.target && e.target.closest ? e.target.closest('.model-pick') : null;
528
+ if (!sel) { return; }
529
+ var alias = sel.getAttribute('data-alias');
530
+ if (!alias) { return; }
531
+ modelChoiceIds[alias] = sel.value;
532
+ var opt = sel.options[sel.selectedIndex];
533
+ modelOpenrouterIds[alias] = (opt && opt.getAttribute('data-or')) || null;
534
+ updateWritePreviews();
535
+ });
536
+
537
+ // issue 138 (fix round 1, Finding 1; precedence description corrected in
538
+ // the F2/F6 fix wave; modelChoiceIds provenance corrected in the N2 fix
539
+ // wave): assemble aliasWrites for Finish -- the aliasEdits (Step 3)
540
+ // overlay first; THEN, only for a checked quick-pick default (not a
541
+ // custom/searched one -- isCustomDefault skips this stage entirely), the
542
+ // selected alias's resolved route; then every OTHER alias whose
543
+ // drill-down <select> fired change -- "every OTHER" means every alias
544
+ // but the selected one ONLY when that earlier stage ran, so under a
545
+ // custom default the selected alias (if it has a drilled pick) is
546
+ // handled by THIS stage instead, not skipped. Before the Task-3 dropdown
547
+ // existed there was nothing to lose by writing only the selected alias;
548
+ // now a drilled-down pick on a card that is NOT the checked default
549
+ // would silently vanish without this. Precedence is NOT uniform across
550
+ // the two groups, unlike an earlier version of this comment claimed:
551
+ // only the selected alias clobbers its aliasEdits entry (the ONE place
552
+ // that's permitted -- user-locked decision #2); every OTHER drilled-down
553
+ // alias defers to aliasEdits and is written only when Step 3 left it
554
+ // untouched (see the hasOwnProperty guard below and ruling R6a, which
555
+ // already described the real behaviour correctly).
556
+ //
557
+ // modelChoiceIds is populated from TWO places, not one, unlike an
558
+ // earlier version of this comment claimed: the change handler above
559
+ // (a live drill-down pick), AND the init restore block (F3) -- which
560
+ // seeds it from cfg.aliases for every card whose SAVED value already
561
+ // names one of its shortlist rows, reading the id back out of that
562
+ // card's own server-rendered <option> elements. After the F1 fix that is
563
+ // the NORMAL case, not an edge case: a card the user never touched in
564
+ // THIS session routinely lands in modelChoiceIds anyway, because its
565
+ // saved value already matches its recommendedId. That is still correct
566
+ // to iterate here -- collectAliasWrites' job is to compute what Finish
567
+ // SHOULD write, and a value-identical write is harmless -- but it does
568
+ // mean this function can no longer be read as "only ever fires for
569
+ // aliases the user actually changed this session". buildReview is the
570
+ // layer responsible for not SHOWING those value-identical entries as
571
+ // changes (see its own N1 comment).
572
+ function collectAliasWrites(selectedAlias, isCustomDefault) {
573
+ var aliasWrites = {};
574
+ Object.keys(aliasEdits).forEach(function(k) {
575
+ aliasWrites[k] = aliasEdits[k];
576
+ });
577
+ function writeAliasRoute(alias) {
578
+ var mc = null;
579
+ for (var i = 0; i < modelChoicesData.length; i++) {
580
+ if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
581
+ }
582
+ if (!mc) { return; }
583
+ var routeId = pickRouteFor(mc);
584
+ if (routeId) { aliasWrites[mc.alias] = routeId; }
585
+ }
586
+ if (!isCustomDefault && selectedAlias) {
587
+ // Selecting a quick pick = explicit touch: upgrade that ONE alias
588
+ // to the resolved id via the chosen route (user-locked decision #2).
589
+ // This is the ONE place clobbering aliasEdits is permitted.
590
+ writeAliasRoute(selectedAlias);
591
+ }
592
+ // issue 138 (fix round 2, ruling R6a): every OTHER drilled-down alias is
593
+ // written ONLY when Step 3 left it untouched. hasOwnProperty.call, not
594
+ // the in operator and not a truthiness/not-undefined check:
595
+ // aliasEdits[alias] === null is a MEANINGFUL value here (ipc-setup.js:
596
+ // string = set, null = delete), so a falsy/undefined check would
597
+ // silently resurrect an alias the user explicitly deleted in Step 3,
598
+ // and the in operator or a not-undefined check can be fooled by an
599
+ // inherited Object.prototype property name (this codebase has been
600
+ // bitten by that class of bug before -- see the proto:null guard in
601
+ // src/sidecar/setup.js resolveChoice).
602
+ Object.keys(modelChoiceIds).forEach(function(alias) {
603
+ if (!isCustomDefault && alias === selectedAlias) { return; } // handled above, but only in that branch
604
+ if (Object.prototype.hasOwnProperty.call(aliasEdits, alias)) { return; }
605
+ writeAliasRoute(alias);
606
+ });
607
+ return aliasWrites;
608
+ }
609
+
404
610
  finishBtn.addEventListener('click', async function() {
405
611
  finishBtn.disabled = true; finishBtn.textContent = 'Saving...';
406
612
  try {
407
613
  var r = document.querySelector('input[name="default-model"]:checked');
408
614
  var dm = window.customDefaultModel || (r ? r.value : null);
409
- var aliasWrites = {};
410
- Object.keys(aliasEdits).forEach(function(k) {
411
- aliasWrites[k] = aliasEdits[k];
412
- });
413
- if (!window.customDefaultModel && r) {
414
- // Selecting a quick pick = explicit touch: upgrade that ONE alias
415
- // to the resolved id via the chosen route (user-locked decision #2).
416
- var mc = null;
417
- for (var i = 0; i < modelChoicesData.length; i++) {
418
- if (modelChoicesData[i].alias === r.value) { mc = modelChoicesData[i]; break; }
419
- }
420
- if (mc) {
421
- var routeId = pickRouteFor(mc);
422
- if (routeId) { aliasWrites[mc.alias] = routeId; }
423
- }
424
- }
615
+ var aliasWrites = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
425
616
  await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites, (window.collectCouncilPicks && window.collectCouncilPicks()) || []);
426
617
  var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
427
618
  await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
@@ -551,6 +742,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
551
742
  var idEl = el.querySelector('.write-preview-id');
552
743
  if (idEl && routeId) { idEl.textContent = routeId; }
553
744
  });
745
+ // issue 138: keep the resolved-id line in step with the route/model choice.
746
+ document.querySelectorAll('.model-resolved').forEach(function(el) {
747
+ var alias = el.getAttribute('data-alias');
748
+ var mc = null;
749
+ for (var i = 0; i < modelChoicesData.length; i++) {
750
+ if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
751
+ }
752
+ if (!mc) { return; }
753
+ var id = pickRouteFor(mc);
754
+ if (id) { el.textContent = id; }
755
+ });
554
756
  }
555
757
 
556
758
  document.addEventListener('input', function(e) {
@@ -1,8 +1,8 @@
1
1
  // electron/workspace-ui/live-seats.js
2
- // Seats surface: dash, seatCells, SEATS_PANEL_EXCLUDED_ROLES,
3
- // seatsFromRunStats, deadSeats. Moved verbatim from live-model.js@ac7e7e12:53-285
4
- // (v4.8 PR0 size-gate split, zero behavior). Loads BEFORE live-model.js,
5
- // which re-exports these on window.AmicusLive so no consumer changes.
2
+ // Seats surface: dash, seatCells, SEATS_PANEL_EXCLUDED_ROLES, seatsFromRunStats. Moved
3
+ // verbatim from live-model.js@ac7e7e12:53-285 (v4.8 PR0 size-gate split, zero behavior).
4
+ // `deadSeats` moved too, then PR5c split it to live-dead-seats.js :: deadSeats; it is
5
+ // only re-exported here. Loads BEFORE live-model.js, which re-exports on window.AmicusLive.
6
6
  // ES5 IIFE, dual export, strict-CSP <script> loading — same shape as
7
7
  // every renderer module. Comments write "PR 102", never the hash-number
8
8
  // form (electron-token-drift HEX_RE trips on it — this file is scanned).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
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": [
@@ -63,6 +63,7 @@
63
63
  "models:info": "node bin/amicus.js models",
64
64
  "models:check": "node bin/amicus.js models --check",
65
65
  "models:check:strict": "node bin/amicus.js models --check --strict",
66
+ "check:ci-alias-pins": "node scripts/check-ci-alias-pins.js",
66
67
  "generate-icon": "node scripts/generate-icon.js",
67
68
  "generate-docs": "node scripts/generate-docs.js",
68
69
  "generate-docs:check": "node scripts/generate-docs.js --check",
@@ -14,6 +14,9 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
14
14
  const localProvidersCheck = require('./utils/doctor-local-providers-check');
15
15
  // v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
16
16
  const baseUrlCheck = require('./utils/doctor-base-url-check');
17
+ // B3 (council review of PR 198, issue 195) — the 'aliases' check body,
18
+ // including its --fix repair of fabricated bare ids. Same split rationale.
19
+ const aliasCheck = require('./utils/doctor-alias-check');
17
20
 
18
21
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
19
22
 
@@ -41,6 +44,10 @@ function realDeps() {
41
44
  collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
42
45
  findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
43
46
  findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
47
+ // B3: the narrow fabricated-bare-id repair class (pure detection) + the
48
+ // impure rewrite primitive `doctor --fix` calls when repairing one.
49
+ findFabricatedAliasRepairs: (s, c) => require('./utils/alias-audit').findFabricatedAliasRepairs(s, c),
50
+ repairAlias: (alias, newId) => aliasCheck.repairAlias(alias, newId),
44
51
  hasOpencodeBinary: () => {
45
52
  // Single source of truth shared with the runtime server-start guard.
46
53
  const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
@@ -156,20 +163,10 @@ async function runDoctorChecks(depsOverride = {}) {
156
163
  : { id: 'catalog', name: 'Model catalog', status: 'warn', message: `stale (${hrs}h old)`, hint: 'amicus models --refresh' };
157
164
  }));
158
165
 
159
- checks.push(guard('aliases', 'Model aliases', () => {
160
- const cache = d.readCache();
161
- const catalog = (cache && cache.models) || [];
162
- const sources = d.collectAliasSources();
163
- const stale = d.findStaleAliases(sources, catalog);
164
- const drifted = d.findDriftedStoredAliases(sources, catalog);
165
- if (stale.length === 0 && drifted.length === 0) {
166
- return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
167
- }
168
- const parts = [];
169
- if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
170
- if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
171
- return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
172
- }));
166
+ // B3: self-heals in place under --fix (fabricated bare ids only) see
167
+ // utils/doctor-alias-check.js for the check body and utils/alias-audit.js's
168
+ // findFabricatedAliasRepairs for the detection rule.
169
+ checks.push(guard('aliases', 'Model aliases', () => aliasCheck.evaluateAliasesCheck(d)));
173
170
 
174
171
  checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
175
172
  () => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
@@ -38,7 +38,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
38
38
  // ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS — it returns null and
39
39
  // throws nothing — so a body of literal `null` sailed past the catch above and
40
40
  // `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
41
- // (:129) and parseRevote (:167) below already carried this `!parsed` guard; the judge
41
+ // (parse-stage2.js :: parseDebateDefense) and parseRevote (parse-stage2.js :: parseRevote) below already carried this `!parsed` guard; the judge
42
42
  // path and findings.js's validateFindings did not, which made it an asymmetry among
43
43
  // five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
44
44
  // a `null` body reports exactly what a keyless `{}` body already reported —
@@ -353,6 +353,73 @@ async function printDoctorFinale(deps = {}) {
353
353
  }
354
354
  }
355
355
 
356
+ /**
357
+ * #138 second level: after a family pick, let the user name a SPECIFIC model
358
+ * from that vendor. Returns the chosen catalog id, or null to keep the
359
+ * family default (bare Enter, an empty shortlist, or two invalid entries).
360
+ *
361
+ * The prompt deliberately avoids the substring "Pick a number":
362
+ * tests/sidecar/setup.test.js:392,475 branch on that literal and would
363
+ * answer '' here, leaving new coverage green but vacuous.
364
+ * @param {(q: string) => Promise<string>} ask
365
+ * @param {(line: string) => void} print
366
+ * @param {{suggested: Array<object>, rest: Array<object>, total: number}} shortlist
367
+ * @param {string} vendorPath
368
+ * @returns {Promise<string|null>}
369
+ */
370
+ async function promptForVendorModel(ask, print, shortlist, vendorPath) {
371
+ if (!shortlist || shortlist.total === 0) { return null; }
372
+
373
+ let visible = shortlist.suggested;
374
+ const fmt = (r, i) => {
375
+ const price = r.pricePerMInput === null ? 'n/a' : `$${r.pricePerMInput.toFixed(2)}/M in`;
376
+ const ctx = r.contextLength === null || r.contextLength === undefined ? '' : ` · ctx ${r.contextLength}`;
377
+ return ` ${i + 1}) ${r.id}${ctx} · ${price}${r.isRecommended ? ' (recommended)' : ''}`;
378
+ };
379
+
380
+ const render = () => {
381
+ print('');
382
+ print(`Which ${vendorPath} model?`);
383
+ visible.forEach((r, i) => print(fmt(r, i)));
384
+ if (visible.length < shortlist.total) {
385
+ print(` … ${shortlist.total - visible.length} more`);
386
+ }
387
+ print('');
388
+ };
389
+ render();
390
+
391
+ for (let attempt = 0; attempt < 2; attempt++) {
392
+ const hint = visible.length < shortlist.total ? ", 'a' for all" : '';
393
+ const answer = (await ask(
394
+ `Choose 1-${visible.length}${hint}, a full model id, or Enter to keep the default: `
395
+ ) || '').trim();
396
+
397
+ if (answer === '') { return null; }
398
+ if (answer.toLowerCase() === 'a' && visible.length < shortlist.total) {
399
+ visible = shortlist.suggested.concat(shortlist.rest);
400
+ render();
401
+ attempt--; // expanding the list is not a failed attempt
402
+ continue;
403
+ }
404
+ if (/^\d+$/.test(answer)) {
405
+ const n = Number.parseInt(answer, 10);
406
+ if (n >= 1 && n <= visible.length) { return visible[n - 1].id; }
407
+ }
408
+ if (answer.includes('/')) { return answer; }
409
+ // F1 (council review, PR 196): the final attempt used to fall through
410
+ // silently -- the loop just exited and the caller kept the family
411
+ // default with no feedback at all, so the user's last keystroke
412
+ // visibly did nothing. Both attempts now print, but the last one also
413
+ // states the consequence instead of implying a further retry.
414
+ if (attempt === 0) {
415
+ print(`Invalid choice: "${answer}".`);
416
+ } else {
417
+ print(`Invalid choice: "${answer}". Keeping the family default.`);
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+
356
423
  /**
357
424
  * Run the readline-based setup wizard (headless fallback)
358
425
  *
@@ -486,6 +553,62 @@ async function runReadlineSetup() {
486
553
  const fallback = getDefaultAliases()[chosen.alias];
487
554
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
488
555
  }
556
+
557
+ // #138: offer the family -> model second level. `pick.vendorPath` is
558
+ // the vendor whose catalog rows we drill into; a chosen id REPLACES
559
+ // the flagship route for this alias only. Guarded — a picker failure
560
+ // must never abort a setup run that has already collected keys.
561
+ //
562
+ // R4a (fix round 2, supersedes R4's noUpgrade disjunct; wording
563
+ // corrected in the F7 fix wave -- the previous wording claimed the
564
+ // governing rule was "never ask about the same vendor twice in one
565
+ // run", which this guard cannot implement and does not):
566
+ //
567
+ // `vendorAliasesWritten` holds PROVIDER names (runProviderDefaultPickers
568
+ // adds each entry of `foundKeys`, e.g. 'google'/'openai'/'anthropic'/
569
+ // 'deepseek'), while `chosen.alias` is a FAMILY ALIAS name ('gemini',
570
+ // 'gemini-pro', 'gpt', 'opus', 'deepseek'). The two prompts write
571
+ // DIFFERENT config keys -- the per-provider picker writes
572
+ // `config.aliases[provider]` (e.g. `aliases.google`), this drill-down
573
+ // writes `config.aliases[chosen.alias]` (e.g. `aliases.gemini`) -- so
574
+ // `!vendorAliasesWritten.has(chosen.alias)` only skips the drill-down
575
+ // when the alias STRING happens to collide with an already-written
576
+ // provider name. Today that's 'deepseek' alone (alias 'deepseek' ===
577
+ // provider 'deepseek'); every other family alias never collides, so
578
+ // this drill-down still fires for those even after the per-provider
579
+ // phase ran for that family's vendor -- correctly: the two prompts
580
+ // bind different keys, they are not "the same vendor twice".
581
+ //
582
+ // TRAP: re-keying this guard to `pick.vendorPath` (so it tests the
583
+ // actual vendor instead of the alias-name coincidence) looks like the
584
+ // obvious fix for the mismatch above and is NOT one -- it was
585
+ // measured to delete issue 138's feature entirely for every user
586
+ // holding a direct key, by skipping the drill-down for every family
587
+ // whose per-provider picker already ran this session. Leave this
588
+ // guard exactly as it is.
589
+ //
590
+ // `chosen.noUpgrade` does NOT imply no question was asked for this
591
+ // vendor: a user can type a known alias name (noUpgrade=true) for a
592
+ // vendor the per-provider phase already walked through this run, and
593
+ // that must still be skipped. Whenever noUpgrade is true AND the
594
+ // alias is genuinely unasked-about, `!vendorAliasesWritten.has(...)`
595
+ // is already true on its own, so dropping the noUpgrade disjunct
596
+ // loses no legitimate firing case -- only the double-ask.
597
+ if (pick && !vendorAliasesWritten.has(chosen.alias)) {
598
+ try {
599
+ const { buildModelShortlist } = require('../utils/model-shortlist');
600
+ const shortlist = buildModelShortlist(pick.vendorPath, {
601
+ catalog,
602
+ recommendedId: cfg.aliases[chosen.alias],
603
+ });
604
+ const specific = await promptForVendorModel(
605
+ askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
606
+ );
607
+ if (specific) { cfg.aliases[chosen.alias] = specific; }
608
+ } catch (err) {
609
+ console.log(`Note: couldn't list ${pick.vendorPath} models (${err.message}).`);
610
+ }
611
+ }
489
612
  } else {
490
613
  cfg.default = chosen.modelId;
491
614
  }
@@ -613,6 +736,7 @@ module.exports = {
613
736
  createDefaultConfig,
614
737
  deriveFreeAlias,
615
738
  detectApiKeys,
739
+ promptForVendorModel,
616
740
  runFreeCouncilBranch,
617
741
  runInteractiveSetup,
618
742
  runReadlineSetup,