amicus 4.7.1 → 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.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +988 -1
  3. package/README.md +5 -4
  4. package/docs/CITATIONS.md +122 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/SHIMS.md +1 -1
  7. package/docs/configuration.md +3 -3
  8. package/docs/council.md +179 -32
  9. package/docs/doc-system.md +1 -1
  10. package/docs/publishing.md +2 -0
  11. package/docs/troubleshooting.md +3 -3
  12. package/docs/usage.md +2 -2
  13. package/electron/ipc-setup.js +18 -2
  14. package/electron/main.js +46 -3
  15. package/electron/setup-ui-aliases.js +2 -2
  16. package/electron/setup-ui-model.js +99 -9
  17. package/electron/setup-ui-styles.js +22 -0
  18. package/electron/setup-ui.js +231 -29
  19. package/electron/workspace-ui/index.html +9 -0
  20. package/electron/workspace-ui/live-dead-seats.js +228 -0
  21. package/electron/workspace-ui/live-model.js +10 -236
  22. package/electron/workspace-ui/live-seats.js +126 -0
  23. package/electron/workspace-ui/workspace-app.js +6 -41
  24. package/electron/workspace-ui/workspace-banners.js +95 -0
  25. package/electron/workspace-ui/workspace-lazy.js +55 -12
  26. package/electron/workspace-ui/workspace-matrix.js +2 -2
  27. package/electron/workspace-ui/workspace-panels.js +42 -10
  28. package/electron/workspace-ui/workspace-render.js +2 -2
  29. package/electron/workspace-ui/workspace-seats.js +101 -17
  30. package/package.json +4 -1
  31. package/schemas/council-run-live.schema.json +1 -0
  32. package/schemas/council-run.schema.json +19 -0
  33. package/schemas/council-tally.schema.json +34 -2
  34. package/schemas/council-verdict.schema.json +15 -0
  35. package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
  36. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  37. package/skills/second-opinion/SKILL.md +25 -11
  38. package/src/cli-handlers-doctor.js +18 -14
  39. package/src/council/anonymize.js +80 -11
  40. package/src/council/briefings-chair.js +272 -0
  41. package/src/council/briefings-stage2.js +12 -140
  42. package/src/council/debate.js +120 -15
  43. package/src/council/ledger-join.js +284 -0
  44. package/src/council/ledger-stats.js +100 -0
  45. package/src/council/ledger.js +177 -88
  46. package/src/council/parse-stage2.js +1 -1
  47. package/src/council/peer-split.js +196 -0
  48. package/src/council/report-html.js +12 -5
  49. package/src/council/report-md.js +146 -0
  50. package/src/council/report.js +188 -112
  51. package/src/council/run-assemble.js +100 -79
  52. package/src/council/run-chair.js +17 -1
  53. package/src/council/run-debate-revote.js +268 -0
  54. package/src/council/run-debate.js +92 -102
  55. package/src/council/run-finish.js +70 -0
  56. package/src/council/run-launch.js +45 -18
  57. package/src/council/run-retry-group.js +266 -0
  58. package/src/council/run-retry-keys.js +74 -0
  59. package/src/council/run-retry-launch.js +55 -0
  60. package/src/council/run-retry-notes.js +65 -13
  61. package/src/council/run-retry.js +166 -161
  62. package/src/council/run-stage1-launch.js +27 -10
  63. package/src/council/run-stage1-rows.js +220 -0
  64. package/src/council/run-stage1-superseded.js +156 -0
  65. package/src/council/run-stage2.js +65 -5
  66. package/src/council/run-stages.js +72 -69
  67. package/src/council/run-state.js +1 -1
  68. package/src/council/run-stats-entry.js +71 -0
  69. package/src/council/run-verdict-files.js +52 -0
  70. package/src/council/run.js +42 -52
  71. package/src/council/seats.js +262 -0
  72. package/src/council/stage1-bind.js +142 -0
  73. package/src/council/street-cred.js +258 -0
  74. package/src/council/tally.js +100 -49
  75. package/src/council/verdict.js +49 -1
  76. package/src/headless.js +20 -6
  77. package/src/mcp-tools.js +71 -1
  78. package/src/observe/council-legs.js +7 -1
  79. package/src/observe/live-doc.js +3 -3
  80. package/src/sidecar/fanout-leg.js +22 -1
  81. package/src/sidecar/fanout-wave-io.js +26 -1
  82. package/src/sidecar/fanout.js +4 -10
  83. package/src/sidecar/leg-ids.js +19 -0
  84. package/src/sidecar/models-probe.js +7 -4
  85. package/src/sidecar/reopen-spend.js +1 -1
  86. package/src/sidecar/setup.js +137 -2
  87. package/src/utils/alias-audit.js +81 -3
  88. package/src/utils/config.js +109 -11
  89. package/src/utils/curated-models.js +17 -2
  90. package/src/utils/degrade.js +5 -0
  91. package/src/utils/doctor-alias-check.js +152 -0
  92. package/src/utils/model-canonicalization.js +64 -0
  93. package/src/utils/model-shortlist.js +100 -0
  94. package/src/utils/no-output-backstop.js +1 -1
  95. package/src/utils/provider-default-picker.js +93 -45
  96. package/src/utils/provider-default-prompt.js +1 -1
  97. package/src/utils/quick-picks.js +2 -2
  98. package/src/utils/remediation-hints.js +37 -0
  99. package/src/utils/session-index-prune.js +297 -0
  100. package/src/utils/session-metadata-tmp-sweep.js +1 -1
  101. package/src/workspace/artifact-guard.js +8 -114
  102. package/src/workspace/artifact-names.js +222 -0
  103. package/src/workspace/fold-format.js +9 -6
  104. package/src/workspace/live-normalize.js +6 -2
  105. package/src/workspace/matrix-model.js +141 -19
  106. package/src/workspace/run-detail.js +30 -4
  107. package/src/workspace/seat-space.js +143 -0
@@ -51,7 +51,11 @@ function createDefaultConfig(defaultModel) {
51
51
  const cfg = {
52
52
  ...existing,
53
53
  default: existing.default || defaultModel,
54
- aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
54
+ // Same restatement as the readline gate below (fix round 3, G-2): a spread
55
+ // into `{}` re-materialises Object.prototype. `saveConfig` rebuilds this
56
+ // into its own literal anyway, so this one is defense in depth rather than
57
+ // a measured hole — recorded as such rather than claimed as a fix.
58
+ aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
55
59
  };
56
60
  saveConfig(cfg);
57
61
  logger.info('Default config ensured', {
@@ -128,7 +132,14 @@ function resolveChoice(input, picks, catalog) {
128
132
  return { modelId: input };
129
133
  }
130
134
  const cfg = loadConfig();
131
- const aliases = { ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
135
+ // ⚠️ `__proto__: null` v4.8 SI-22.4 fix round 3 (council G-2). `input` here
136
+ // is FREE-FORM readline text, and the gate below is `aliases[input] !==
137
+ // undefined`, so on a plain literal `toString` / `valueOf` / `constructor` /
138
+ // `hasOwnProperty` all measured TRUE and returned `{alias: input, noUpgrade:
139
+ // true}` — setup accepted them as existing aliases. Spreading a
140
+ // null-prototype object into a bare `{}` produces a PLAIN object again, so the
141
+ // curated-models fix does not reach this literal; the seed has to be restated.
142
+ const aliases = { __proto__: null, ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
132
143
  if (aliases[input] !== undefined) {
133
144
  return { alias: input, noUpgrade: true };
134
145
  }
@@ -342,6 +353,73 @@ async function printDoctorFinale(deps = {}) {
342
353
  }
343
354
  }
344
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
+
345
423
  /**
346
424
  * Run the readline-based setup wizard (headless fallback)
347
425
  *
@@ -475,6 +553,62 @@ async function runReadlineSetup() {
475
553
  const fallback = getDefaultAliases()[chosen.alias];
476
554
  if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
477
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
+ }
478
612
  } else {
479
613
  cfg.default = chosen.modelId;
480
614
  }
@@ -602,6 +736,7 @@ module.exports = {
602
736
  createDefaultConfig,
603
737
  deriveFreeAlias,
604
738
  detectApiKeys,
739
+ promptForVendorModel,
605
740
  runFreeCouncilBranch,
606
741
  runInteractiveSetup,
607
742
  runReadlineSetup,
@@ -1,10 +1,21 @@
1
1
  /**
2
- * Alias Audit (F5) — report + suggest, never auto-repair.
2
+ * Alias Audit (F5) — report + suggest for most classes; doctor --fix auto-repairs one narrow, mechanically-unambiguous class (B3).
3
3
  *
4
4
  * Finds aliases/routes pointing at models absent from the catalog and
5
5
  * suggests current same-vendor replacements. Pure functions over inputs;
6
6
  * collectAliasSources() does the gathering. Consumed by `amicus models
7
7
  * --check` and the npm wrapper scripts.
8
+ *
9
+ * `findFabricatedAliasRepairs()` (B3, council review of PR 198) is the one
10
+ * exception to "never auto-repair": it detects the single class `doctor
11
+ * --fix` can safely rewrite unattended -- a bare id `classifyModel` proves
12
+ * `invalid` on the `direct` gateway AND for which the catalog holds an
13
+ * unambiguous OpenRouter twin (`pairAcrossGateways`, never string
14
+ * concatenation) -- still pure detection here; the actual write lives in
15
+ * `doctor-alias-check.js`'s `repairAlias()`. Every OTHER class this module
16
+ * finds (typo, retired model, ambiguous twin, drifted-but-live) stays
17
+ * report-and-suggest only, same as before -- this module does not become a
18
+ * general auto-repair tool.
8
19
  */
9
20
 
10
21
  'use strict';
@@ -132,7 +143,10 @@ function suggestReplacements(staleModel, catalog, n = 3) {
132
143
  * fresh `amicus setup` would seed today — the v4.6.1 release-gate class
133
144
  * (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
134
145
  * findStaleAliases passes it, no longer what the family resolves to).
135
- * Report + suggest, never auto-repair (this module's charter).
146
+ * Report + suggest only -- unlike `findFabricatedAliasRepairs` below (B3's
147
+ * narrow, mechanically-unambiguous exception), THIS function never
148
+ * auto-repairs: a drifted-but-live target has no single unambiguous
149
+ * "correct" answer to converge on the way a fabricated id does.
136
150
  *
137
151
  * Only user-config rows are checked (defaults/curated follow the catalog by
138
152
  * construction), only for aliases that are quick-pick families (a custom
@@ -178,4 +192,68 @@ function findDriftedStoredAliases(sources, catalog) {
178
192
  .map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
179
193
  }
180
194
 
181
- module.exports = { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements };
195
+ /**
196
+ * B3 (council review of PR 198, issue 195): the narrow, mechanically-
197
+ * unambiguous class of stored alias `doctor --fix` may repair -- a bare
198
+ * `<vendor>/<model>` id in a 'user-config' row (what's actually persisted to
199
+ * `config.aliases`, the only source a repair can rewrite) that
200
+ * `classifyModel` proves `invalid` on the `direct` gateway AND for which the
201
+ * catalog contains an unambiguous OpenRouter twin (`pairAcrossGateways` --
202
+ * never string-concatenation). This is exactly the class the pre-fix
203
+ * v4.8.0 `chooseRowId`/`applyProviderDefault` could persist (an unconditional
204
+ * `openrouter/` strip with no catalog evidence); PR 198's
205
+ * `directFormIfProven` (model-canonicalization.js) now refuses to write it
206
+ * going forward, so this function finds the ones already ON DISK and
207
+ * converges them onto the same OpenRouter-prefixed answer the picker would
208
+ * offer today, rather than inventing a third one.
209
+ *
210
+ * Deliberately narrower than findStaleAliases: a typo'd, retired, or
211
+ * user-invented id also classifies `invalid`, but `pairAcrossGateways` can
212
+ * only find a twin when the extracted vendor + version token names a REAL
213
+ * model that still exists, under that exact normalized name, in the
214
+ * catalog's OpenRouter namespace -- a typo or a genuinely dead model can
215
+ * never satisfy that. Ambiguous (>1 match) or absent twins are left
216
+ * untouched -- still reported as stale by findStaleAliases, never repaired.
217
+ *
218
+ * DIVERGENT_VENDORS (e.g. anthropic) are excluded on purpose:
219
+ * model-canonicalization.js gates that set FIRST and unconditionally, so its
220
+ * predicates never strip a divergent vendor's prefix in the first place --
221
+ * a bare divergent-vendor alias could not have been produced by the bug
222
+ * this repairs, so treating one as fabricated here would be inventing a new
223
+ * class, not converging on the picker's own answer.
224
+ *
225
+ * Empty/absent catalog -> [] : classifyModel can only return 'unknown' with
226
+ * no catalog rows, and 'unknown' never authorises a repair -- no positive
227
+ * evidence, no write.
228
+ * @param {Array<{alias:string,model:string,source:string}>} sources
229
+ * @param {Array<{id:string}>} catalog
230
+ * @returns {Array<{alias:string,oldId:string,newId:string}>}
231
+ */
232
+ function findFabricatedAliasRepairs(sources, catalog) {
233
+ if (!catalog || catalog.length === 0) { return []; }
234
+ const { classifyModel } = require('./model-classification');
235
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
236
+ const { DIVERGENT_VENDORS } = require('./curated-models');
237
+ const catalogInfo = { models: catalog };
238
+
239
+ const out = [];
240
+ for (const { alias, model, source } of sources) {
241
+ if (source !== 'user-config') { continue; }
242
+ if (typeof model !== 'string' || model.startsWith('openrouter/')) { continue; } // not bare
243
+ const parts = model.split('/');
244
+ if (parts.length < 2) { continue; } // not a <vendor>/<model> shape
245
+ const vendor = parts[0];
246
+ if (DIVERGENT_VENDORS.has(vendor)) { continue; }
247
+ const versionToken = parts.slice(1).join('/');
248
+ if (classifyModel(model, 'direct', catalogInfo) !== 'invalid') { continue; }
249
+ const paired = pairAcrossGateways(vendor, versionToken, catalogInfo);
250
+ if (!paired.openrouter) { continue; } // ambiguous or absent twin -- leave as a warning
251
+ out.push({ alias, oldId: model, newId: paired.openrouter });
252
+ }
253
+ return out;
254
+ }
255
+
256
+ module.exports = {
257
+ collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements,
258
+ findFabricatedAliasRepairs,
259
+ };
@@ -60,7 +60,16 @@ function saveConfig(configData) {
60
60
  if (configData && configData.aliases) {
61
61
  const cleaned = {};
62
62
  for (const [key, value] of Object.entries(configData.aliases)) {
63
- if (key === 'null' || !value || typeof value !== 'string' || value === 'null') {
63
+ // `key === '__proto__'` v4.8 SI-22.4 fix round 3 (council G-5). Without
64
+ // it the write below (`cleaned[key] = value`) hit Object.prototype's
65
+ // INHERITED `__proto__` setter, which ignores a string, so the alias
66
+ // vanished with NO "Removing invalid alias" notice — the only silent
67
+ // removal in this loop. Rejecting it explicitly puts it on the same
68
+ // footing as the `'null'` key beside it: same branch, same message.
69
+ // (No pollution was possible either way — only strings reach this line,
70
+ // and the setter ignores them — so this is an announcement fix, not a
71
+ // security one. Stated that way on purpose.)
72
+ if (key === 'null' || key === '__proto__' || !value || typeof value !== 'string' || value === 'null') {
64
73
  process.stderr.write(
65
74
  `Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
66
75
  );
@@ -78,7 +87,14 @@ function saveConfig(configData) {
78
87
 
79
88
  /** @returns {object} Copy of the default alias map */
80
89
  function getDefaultAliases() {
81
- return { ...DEFAULT_ALIASES };
90
+ // ⚠️ `__proto__: null` must be RESTATED here, not inherited. A spread into a
91
+ // bare `{}` literal produces a plain object again, so fixing the builders in
92
+ // curated-models.js does NOT reach this copy — measured, not assumed. Its
93
+ // consumers index it: `sidecar/setup.js:479` (`getDefaultAliases()[alias]`)
94
+ // and `electron/setup-ui-aliases.js :: buildAliasEditorHTML`
95
+ // (`aliases[key] !== undefined`).
96
+ // Named mutant "BUILDERPROTO" covers this line too.
97
+ return { __proto__: null, ...DEFAULT_ALIASES };
82
98
  }
83
99
 
84
100
  /**
@@ -216,7 +232,44 @@ function checkConfigChanged(currentHash) {
216
232
  function getEffectiveAliases() {
217
233
  const config = loadConfig();
218
234
  const userAliases = (config && config.aliases) || {};
219
- return { ...DEFAULT_ALIASES, ...userAliases };
235
+ // `__proto__: null` v4.8 SI-22.4 fix round 2 (council B1). This table is
236
+ // read with BARE INDEXING by five gates, so on a normal object a member
237
+ // literally named 'toString' / 'constructor' / 'valueOf' / 'hasOwnProperty'
238
+ // resolved off Object.prototype to a truthy Function and was treated as a
239
+ // KNOWN ALIAS. Measured, not argued:
240
+ // resolveModel('toString') -> the Function itself,
241
+ // typeof 'function', where every caller expects a model-id STRING
242
+ // (`:111` and `:142` gate on `!== undefined`, which a Function passes)
243
+ // classifyCouncilMembers(['toString '], []) -> ACCEPTED, i.e. runnable
244
+ // The SAME defect class this release already closed at other lookup tables —
245
+ // `tally.js :: VERDICTS`, `report.js :: SYMBOL`, `debate.js :: PAST_TENSE`
246
+ // (all `__proto__: null`), plus `street-cred.js :: perJudgeRank` and
247
+ // `report.js :: ROLE_SUFFIX` (both `Object.create(null)`, the same guarantee
248
+ // in the other spelling). The ALIAS table was not among them.
249
+ // ⚠️ SI-22.4 WIDENED it and that is why it is fixed here: at BASE the padded
250
+ // spelling ('toString ') missed the prototype and was correctly dropped;
251
+ // trimming before the lookup landed it on the inherited property. The
252
+ // unpadded spelling was already accepted, so restoring only the padded case
253
+ // would take more code AND deliberately preserve a known hole.
254
+ // ⚠️ Fixed HERE, not at the call sites — one line closes all five, each
255
+ // MEASURED at its own gate expression with `'toString'` (not inferred from
256
+ // this one): `resolveModel` `:111`/`:142` (`!== undefined` true→false) ·
257
+ // `classifyCouncilMembers` (accepted→dropped, end to end) ·
258
+ // `council/presets-cli.js:41` (`amicus council save`: unresolved false→true) ·
259
+ // `pack/pack-validate.js:71` (`seatOk` true→false) ·
260
+ // `utils/route-launch.js:205` (`isAlias` true→false).
261
+ // No consumer breaks: every reference either indexes (`aliases[key]`) or
262
+ // iterates own-enumerable keys (`Object.entries`/`Object.keys` —
263
+ // `buildProviderModels`, `formatAliasNames`, `mcp-tools.js :: getGuideText`,
264
+ // `sidecar/models.js :: aliasMarks`), and both behave identically on a
265
+ // null-prototype object. NOTHING calls a method ON the object — swept
266
+ // uncapped over `src/` and `electron/` for `aliases.<x>`, `in aliases`,
267
+ // `Object.values`, `JSON.stringify`, spread and `for…in`.
268
+ // A `__proto__` key inside the user's own config.json is copied as an ORDINARY
269
+ // own property by spread (never the setter), so the prototype stays null —
270
+ // measured on both a literal and a `JSON.parse`d source.
271
+ // Named mutant "PROTOALIASES": drop `__proto__: null` from the literal below.
272
+ return { __proto__: null, ...DEFAULT_ALIASES, ...userAliases };
220
273
  }
221
274
 
222
275
  /**
@@ -414,11 +467,42 @@ function getCouncilWithSource(name, catalog = []) {
414
467
  * at the last refresh; the leg itself fails pre-flight with the actionable
415
468
  * local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
416
469
  * catalog that omits the resolved id is a definitive drop.
417
- * @param {string[]} members raw council members (aliases or provider/model ids)
470
+ *
471
+ * WHITESPACE (v4.8 SI-22.4). Each member is TRIMMED before it is classified,
472
+ * closing a divergence: `--models` already trimmed
473
+ * (`sidecar/fanout-validate.js :: parseModelsList`, and `cli-council-run-bench.js
474
+ * :: parseList` on the council surface) while `--council` did not, so the same
475
+ * stray space was benign on one flag and, here, converted a typo into a dropped
476
+ * member and a degraded (2) exit. ⚠️ The dominant effect is RESURRECTION, not
477
+ * de-duplication: a padded member that is dropped today starts RUNNING, which
478
+ * is a new paid leg. Where the trim makes two members collide, the bench
479
+ * becomes a real twin and `seats.js :: buildSeats` mints `alias#N` for both.
480
+ * An all-whitespace member trims to `''`, which no alias table names, so gate 1
481
+ * below drops it — the `.filter(Boolean)` half of `parseModelsList`'s shape,
482
+ * reached without a third `reason` string (see the tripwire note below).
483
+ * @param {string[]} members council members as configured — aliases or
484
+ * provider/model ids, trimmed per member here. ⚠️ This is the only place the
485
+ * preset READ path trims — NOT the only place the preset path trims at all,
486
+ * and NOT the only trim a member meets. The WRITE side already trimmed:
487
+ * `council/presets-cli.js:34` (`amicus council save`) stores
488
+ * `.split(',').map(m => m.trim()).filter(Boolean)`, and the only other writer
489
+ * of `cfg.councils` in `src/` (`sidecar/setup.js:593`, the seeded `free`
490
+ * council) composes its members from generated/existing alias KEYS, which
491
+ * cannot carry user padding. So a padded member in `cfg.councils` comes from
492
+ * a hand-edited `config.json`, and that is the case this trim serves.
493
+ * Downstream, both council
494
+ * surfaces re-join the expanded bench and re-parse it downstream —
495
+ * `cli-handlers-fanout.js:91` → `:119` → `sidecar/fanout.js ::
496
+ * validateFanoutModels` → `parseModelsList`, and `mcp-council-run.js:177` →
497
+ * the spawned child's `cli-council-run-bench.js :: parseList` — so a member
498
+ * that somehow kept padding past this point would still be trimmed there
418
499
  * @param {Array<{id:string}>} [catalog]
419
500
  * @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
420
501
  * `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
421
502
  * callers keep working); `droppedMembers` additively pairs each with WHY.
503
+ * ⚠️ Both report the member RAW — untrimmed, byte-for-byte as configured
504
+ * (v4.8 SI-22.4, R22.4-2) — so a user can find the offending string in their
505
+ * own config. Only `models` carries the trimmed value.
422
506
  *
423
507
  * Standing note (D18, v4.7 PR5): each `droppedMembers` entry is `{member, reason}`
424
508
  * (that is the real key — BACKLOG.md's description of this shape had drifted to
@@ -442,18 +526,29 @@ function classifyCouncilMembers(members, catalog = []) {
442
526
  const models = [];
443
527
  const dropped = [];
444
528
  const droppedMembers = [];
445
- for (const member of members) {
529
+ for (const raw of members) {
530
+ // v4.8 SI-22.4. Trim BEFORE gate 1 below, never after: a padded ALIAS
531
+ // ('gpt ') must reach the alias table as written in the table, and a padded
532
+ // full id ('openai/gpt-5 ') must reach the catalog lookup clean. Trimming
533
+ // downstream of either gate would leave both misses in place. Non-strings
534
+ // pass through untouched so their `.includes` still throws exactly as it
535
+ // did before this line existed. Named mutant "NOTRIM": drop the `.trim()`.
536
+ const member = typeof raw === 'string' ? raw.trim() : raw;
446
537
  const id = member.includes('/') ? member : aliases[member];
538
+ // R22.4-2: `models` gets the TRIMMED value, `dropped`/`droppedMembers` get
539
+ // `raw` — a member still dropped after trimming is reported as the user
540
+ // wrote it, or they cannot grep their own config for it. Named mutant
541
+ // "TRIMDROPPED": report `member` instead of `raw` in the two drop branches.
447
542
  if (!id) { // alias no longer resolves
448
- dropped.push(member);
449
- droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
543
+ dropped.push(raw);
544
+ droppedMembers.push({ member: raw, reason: 'alias no longer resolves to a known model' });
450
545
  continue;
451
546
  }
452
547
  const vendor = typeof id === 'string' ? id.split('/')[0] : '';
453
548
  if (isLocalProvider(vendor)) { models.push(member); continue; }
454
549
  if (known.size > 0 && !known.has(id)) { // delisted model
455
- dropped.push(member);
456
- droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
550
+ dropped.push(raw);
551
+ droppedMembers.push({ member: raw, reason: 'resolved id is not present in the cached model catalog' });
457
552
  continue;
458
553
  }
459
554
  models.push(member);
@@ -465,8 +560,11 @@ function classifyCouncilMembers(members, catalog = []) {
465
560
  * Expand a saved council into a runnable members list, degrading gracefully.
466
561
  * Unresolvable aliases and delisted ids are dropped with a warning rather than
467
562
  * fail-fast-aborting the whole wave (classification: classifyCouncilMembers
468
- * above). Returns members RAW (alias or id) leg-time validation resolves
469
- * them again.
563
+ * above). Returns members UNRESOLVED (the alias or id as configured, never the
564
+ * id an alias maps to) — leg-time validation resolves them again. ⚠️ Not
565
+ * byte-identical to the configured string since v4.8 SI-22.4: classification
566
+ * trims each member, so `models[i]` is the configured member minus any
567
+ * surrounding whitespace. `dropped`/`droppedMembers` still carry it raw.
470
568
  *
471
569
  * Resolution order: user config (`config.councils`) is checked first; when
472
570
  * `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
@@ -111,6 +111,13 @@ const CARDLESS = [
111
111
  { alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
112
112
  { alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
113
113
  { alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
114
+ // inkling added 2026-08-14: the council-review workflow's default bench
115
+ // names it, and this table is the FLOOR a runner falls back to when no
116
+ // alias map is provisioned (workflow_call callers, forks) — there, a
117
+ // locally defined alias still resolves to nothing. Pinned to the full
118
+ // model, not `inkling-small`: the bench seat wants the flagship's
119
+ // judgment. `:batch` is not pinned (wrong for an interactive council leg).
120
+ { alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
114
121
  ];
115
122
 
116
123
  /**
@@ -242,7 +249,14 @@ function directFormProvenance() {
242
249
  * without corrupting divergent-vendor ids (e.g. Anthropic's dash format).
243
250
  */
244
251
  function toGatewayRoutes() {
245
- const out = {};
252
+ // `__proto__: null` — v4.8 SI-22.4 round 3 (G-1). Read by BARE INDEXING
253
+ // downstream, so a plain `{}` let an alias named 'toString'/'constructor'/
254
+ // 'valueOf'/'hasOwnProperty' resolve off Object.prototype to a truthy
255
+ // Function — including on the auto-repair path (`alias-resolver.js ::
256
+ // autoRepairAlias`), which `getEffectiveAliases`'s own fix could never reach.
257
+ // Full measurement + why THREE seeds were needed:
258
+ // tests/council/preset-trim-mutants.js :: BUILDERPROTO (the named mutant).
259
+ const out = { __proto__: null };
246
260
  for (const f of FAMILIES) { out[f.alias] = gatewayRoutesFor(f.vendorPath, f.fallback); }
247
261
  for (const e of CARDLESS) { out[e.alias] = gatewayRoutesFor(vendorOf(e.routes.openrouter), e.routes); }
248
262
  return out;
@@ -262,7 +276,8 @@ function toGatewayRoutes() {
262
276
  * shipped defaults.
263
277
  */
264
278
  function toDefaultAliases() {
265
- const out = {};
279
+ // `__proto__: null` — see toGatewayRoutes above. Becomes DEFAULT_ALIASES.
280
+ const out = { __proto__: null };
266
281
  for (const [alias, routes] of Object.entries(toGatewayRoutes())) {
267
282
  out[alias] = routes.direct || routes.openrouter;
268
283
  }
@@ -17,6 +17,11 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
17
17
  'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
18
18
  'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
19
19
  'stage1-retry',
20
+ // v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
21
+ // returned legs but none its own; a returned leg matching no roster slot; and (T5.5, `-rv` only)
22
+ // a leg that DID match a slot but whose join key names no judge the wave launched.
23
+ // Never a guess — silent mis-attribution is the failure seat identity exists to kill (§4.4).
24
+ 'seat-unbound',
20
25
  'internal',
21
26
  // doctor channels
22
27
  'doctor-check-failed', 'doctor-fix',
@@ -0,0 +1,152 @@
1
+ // src/utils/doctor-alias-check.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module utils/doctor-alias-check
6
+ * The `aliases` doctor check ("Model aliases"), split out of
7
+ * src/cli-handlers-doctor.js to keep that file under the 300-line gate
8
+ * (mirrors doctor-engine-check.js / doctor-electron-mcp-check.js /
9
+ * doctor-base-url-check.js / doctor-local-providers-check.js -- same reason,
10
+ * a different check).
11
+ *
12
+ * B3 (council review of PR 198, issue 195): `doctor --fix` repairs exactly
13
+ * one narrow class of stored alias -- see alias-audit.js's
14
+ * `findFabricatedAliasRepairs` for the detection rule (classifies `invalid`
15
+ * on the `direct` gateway AND has an unambiguous OpenRouter twin) and why it
16
+ * cannot false-positive a typo, a retired model, or a user-invented id.
17
+ * Repair = rewrite `config.aliases[alias]` to that catalog-confirmed
18
+ * OpenRouter id -- read-modify-write, no-clobber (mirrors
19
+ * `applyProviderDefault`, provider-default-picker.js). Every OTHER
20
+ * stale/drifted alias is left untouched and stays a warning, hinting at
21
+ * `amicus models --check` same as before this PR.
22
+ *
23
+ * A3 (council review of PR 198): the repair ACTION additionally requires the
24
+ * cached catalog to be FRESH (same `MAX_CATALOG_AGE_MS` window as doctor's
25
+ * own `catalog` check, cli-handlers-doctor.js). `readCache()` here reads the
26
+ * exact same cache doctor's `catalog` check may independently report as
27
+ * `stale (Nh old)` -- without this gate, `--fix` would rewrite a user's
28
+ * config from data the SAME run just called untrustworthy. A stale catalog
29
+ * can be missing rows that would make a "fabricated" id look repairable when
30
+ * it is merely unfetched, so a stale catalog declines the repair (explaining
31
+ * why via `repairFabricatedAliasStaleCatalog`) rather than writing on
32
+ * unverified evidence; detection/reporting is unaffected either way.
33
+ */
34
+
35
+ const HINTS = require('./remediation-hints');
36
+
37
+ // Mirrors cli-handlers-doctor.js's own MAX_CATALOG_AGE_MS (which itself
38
+ // mirrors model-catalog.js's DEFAULT_MAX_AGE_MS) -- duplicated rather than
39
+ // imported to avoid a require cycle (cli-handlers-doctor.js requires this
40
+ // module at load time, before its own module.exports exists).
41
+ const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
42
+
43
+ /**
44
+ * @param {{fetchedAt?: number}|null} cache
45
+ * @returns {boolean} true when `cache` exists, has a numeric `fetchedAt`, and
46
+ * is no older than `MAX_CATALOG_AGE_MS` -- the same test doctor's `catalog`
47
+ * check applies to decide `ok` vs `stale (Nh old)`.
48
+ */
49
+ function isCatalogFresh(cache) {
50
+ if (!cache || typeof cache.fetchedAt !== 'number') { return false; }
51
+ return (Date.now() - cache.fetchedAt) <= MAX_CATALOG_AGE_MS;
52
+ }
53
+
54
+ /**
55
+ * Rewrite one alias's stored value in place. Read-modify-write / no-clobber
56
+ * -- preserves `config.default` and every other alias/key (same contract as
57
+ * `applyProviderDefault`, provider-default-picker.js).
58
+ * @param {string} alias
59
+ * @param {string} newId verbatim catalog id (an OpenRouter-namespace id from
60
+ * `pairAcrossGateways` -- never hand-derived by string concatenation)
61
+ */
62
+ function repairAlias(alias, newId) {
63
+ const { loadConfig, saveConfig } = require('./config');
64
+ const config = loadConfig() || {};
65
+ if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
66
+ config.aliases[alias] = newId;
67
+ saveConfig(config);
68
+ }
69
+
70
+ /** One pass: sources + both existing audits + the repairable set, over the same catalog. */
71
+ function computeState(d, catalog) {
72
+ const sources = d.collectAliasSources();
73
+ return {
74
+ sources,
75
+ stale: d.findStaleAliases(sources, catalog),
76
+ drifted: d.findDriftedStoredAliases(sources, catalog),
77
+ repairable: d.findFabricatedAliasRepairs(sources, catalog),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * @param {{readCache: () => ({models?: Array}|null), collectAliasSources: () => Array,
83
+ * findStaleAliases: (s:Array, c:Array) => Array, findDriftedStoredAliases: (s:Array, c:Array) => Array,
84
+ * findFabricatedAliasRepairs: (s:Array, c:Array) => Array<{alias:string,oldId:string,newId:string}>,
85
+ * fix?: boolean, repairAlias?: (alias:string, newId:string) => void}} d
86
+ * @returns {{id,name,status,message,hint,fixed?,fixDetail?}}
87
+ */
88
+ function evaluateAliasesCheck(d) {
89
+ const id = 'aliases';
90
+ const name = 'Model aliases';
91
+ const cache = d.readCache();
92
+ const catalog = (cache && cache.models) || [];
93
+ const catalogFresh = isCatalogFresh(cache);
94
+
95
+ let state = computeState(d, catalog);
96
+ let fixFields = {};
97
+
98
+ // Only under --fix, only when there is something in the narrow,
99
+ // mechanically-unambiguous class to repair (rule 1), and only on a FRESH
100
+ // catalog (A3) -- a failed individual rewrite is best-effort -- it simply
101
+ // stays a warning, same as one findStaleAliases could never resolve.
102
+ if (d.fix && state.repairable.length > 0 && catalogFresh) {
103
+ const repaired = [];
104
+ for (const r of state.repairable) {
105
+ try { d.repairAlias(r.alias, r.newId); repaired.push(r); }
106
+ catch { /* best-effort -- an unrepaired alias just stays a warning below */ }
107
+ }
108
+ if (repaired.length > 0) {
109
+ // Rule 6: announce every repair, naming the alias and both ids -- this
110
+ // fixDetail flows into the 'heal' degrade's `why` field (doctor-degrade.js).
111
+ const detail = repaired.map((r) => `'${r.alias}' (${r.oldId} -> ${r.newId})`).join('; ');
112
+ fixFields = {
113
+ fixed: true,
114
+ fixDetail: `rewrote ${repaired.length} fabricated alias(es) to its catalog-confirmed OpenRouter id: ${detail}`,
115
+ };
116
+ // Rule 5 (idempotency): recompute from a fresh config read so both this
117
+ // run's message and a second --fix run see the post-repair reality, not
118
+ // the pre-repair snapshot -- a repaired alias must not still count as
119
+ // stale/repairable below.
120
+ state = computeState(d, catalog);
121
+ }
122
+ }
123
+
124
+ const { stale, drifted, repairable } = state;
125
+ if (stale.length === 0 && drifted.length === 0) {
126
+ return {
127
+ id, name, status: 'ok',
128
+ message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null,
129
+ ...fixFields,
130
+ };
131
+ }
132
+ const parts = [];
133
+ if (stale.length) { parts.push(`${stale.length} stale: ${stale.map((s) => s.alias).join(', ')}`); }
134
+ if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map((s) => s.alias).join(', ')}`); }
135
+ // Rule 1: without --fix, report the repairable count and the hint, change
136
+ // nothing. A3: when the catalog is stale, say so explicitly rather than
137
+ // offering a fix that will silently decline to write.
138
+ if (repairable.length) {
139
+ parts.push(catalogFresh
140
+ ? `${repairable.length} fixable via doctor --fix`
141
+ : `${repairable.length} fixable via doctor --fix once the catalog is refreshed (catalog is stale)`);
142
+ }
143
+ return {
144
+ id, name, status: 'warn', message: parts.join('; '),
145
+ hint: repairable.length === 0
146
+ ? 'amicus models --check'
147
+ : (catalogFresh ? HINTS.repairFabricatedAlias : HINTS.repairFabricatedAliasStaleCatalog),
148
+ ...fixFields,
149
+ };
150
+ }
151
+
152
+ module.exports = { evaluateAliasesCheck, repairAlias };