amicus 4.9.7 → 4.10.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -194,7 +194,7 @@ async function runCheck(args) {
194
194
  // empty namespace explains stale/absent aliases downstream, and staying
195
195
  // silent about it is the original defect.
196
196
  for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
197
- const driftLines = buildFallbackDriftReport(catalog);
197
+ const driftLines = buildFallbackDriftReport(catalogInfo);
198
198
  if (stale.length === 0 && drifted.length === 0) {
199
199
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
200
200
  } else if (stale.length > 0) {
@@ -202,7 +202,11 @@ async function runCheck(args) {
202
202
  process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
203
203
  if (s.suggestions.length > 0) {
204
204
  process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
205
- process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
205
+ // #238 D4: a user-config row is reviewable in the picker; a shipped pin
206
+ // that went stale can only be pinned OVER until the next release.
207
+ process.stdout.write(s.source === 'user-config'
208
+ ? ' fix: amicus aliases --review\n'
209
+ : ` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]} (pins over the stale shipped default)\n`);
206
210
  } else {
207
211
  process.stdout.write(' no same-vendor candidates in catalog\n');
208
212
  }
@@ -210,7 +214,7 @@ async function runCheck(args) {
210
214
  }
211
215
  for (const dr of drifted) {
212
216
  process.stdout.write(`DRIFTED: ${dr.alias} -> ${dr.stored} (stored; current resolution: ${dr.current})\n`);
213
- process.stdout.write(` stored aliases don't follow catalog updates — refresh: amicus setup --add-alias ${dr.alias}=${dr.current}\n`);
217
+ process.stdout.write(' stored aliases don\'t follow catalog updates — review: amicus aliases --review\n');
214
218
  }
215
219
  if (driftLines.length > 0) {
216
220
  process.stdout.write('Pinned fallback drift:\n');
@@ -233,12 +237,21 @@ async function runCheck(args) {
233
237
 
234
238
  /**
235
239
  * Non-blocking drift report: pinned family fallbacks vs live resolution.
236
- * Empty catalog [] (cannot check). Never affects the exit code.
237
- * @param {Array<{id:string}>} catalog
240
+ * Accepts a catalogInfo (`{models, providerFailures}`) or a bare models array
241
+ * (older callers). Empty catalog → [] (cannot check). #238 §5: when the
242
+ * openrouter namespace itself was REJECTED this run, the catalog is missing the
243
+ * rows that make a pin look current, and a drift line computed from it would
244
+ * propose a downgrade — so the report is empty for that catalog. Never affects
245
+ * the exit code.
246
+ * @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}|Array<{id:string}>} catalogOrInfo
238
247
  * @returns {string[]} human-readable warning lines
239
248
  */
240
- function buildFallbackDriftReport(catalog) {
241
- if (!catalog || catalog.length === 0) { return []; }
249
+ function buildFallbackDriftReport(catalogOrInfo) {
250
+ const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
251
+ const catalog = info.models || [];
252
+ if (catalog.length === 0) { return []; }
253
+ const failures = Array.isArray(info.providerFailures) ? info.providerFailures : [];
254
+ if (failures.some(f => f && f.provider === 'openrouter')) { return []; }
242
255
  const lines = [];
243
256
  for (const f of getFamilies()) {
244
257
  const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
@@ -20,8 +20,10 @@ const {
20
20
  resolveExistingSessionDir
21
21
  } = require('../session-manager');
22
22
 
23
- /** Standard heartbeat interval in milliseconds */
24
- const HEARTBEAT_INTERVAL = 15000;
23
+ // HEARTBEAT_INTERVAL + createHeartbeat live in ./heartbeat (size-gate split:
24
+ // this file was already at the 300-line ceiling — spec 2026-09-11 §4 PR 2).
25
+ // Re-exported below so no caller changes.
26
+ const { HEARTBEAT_INTERVAL, createHeartbeat } = require('./heartbeat');
25
27
 
26
28
  /** Session path utilities - eliminates magic strings across modules */
27
29
  const SessionPaths = {
@@ -122,38 +124,6 @@ function outputSummary(summary) {
122
124
  console.log(fenceSidecarOutput(summary));
123
125
  }
124
126
 
125
- /**
126
- * Create a heartbeat that writes status to stderr periodically.
127
- * When sessionDir is provided, includes message count and latest activity.
128
- *
129
- * @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
130
- * @param {string} [sessionDir] - Session directory to read progress from
131
- * @returns {{ stop: () => void }}
132
- */
133
- function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
134
- const startTime = Date.now();
135
- const intervalId = setInterval(() => {
136
- const elapsed = Math.round((Date.now() - startTime) / 1000);
137
- const mins = Math.floor(elapsed / 60);
138
- const secs = elapsed % 60;
139
- const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
140
-
141
- if (sessionDir) {
142
- const { readProgress } = require('./progress');
143
- const progress = readProgress(sessionDir);
144
- process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
145
- } else {
146
- process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
147
- }
148
- }, interval);
149
-
150
- return {
151
- stop() {
152
- clearInterval(intervalId);
153
- }
154
- };
155
- }
156
-
157
127
  /**
158
128
  * Execute sidecar in either headless or interactive mode
159
129
  * Consolidates the if/else pattern duplicated across start, resume, continue
@@ -228,6 +198,7 @@ async function executeMode(options) {
228
198
  * @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
229
199
  * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
230
200
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
201
+ * @param {Object<string, object>} [options.agents] - Extra agents to register (council seat agents)
231
202
  * @param {string[]} [options.models] - Resolved executable id(s) actually launched on this
232
203
  * server (#61 Task 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout)
233
204
  * has no single default `config.model`, so this registers ALL of them in provider.models
@@ -257,6 +228,8 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
257
228
  if (options.models) { serverOptions.models = options.models; }
258
229
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
259
230
  if (options.agentName) { serverOptions.agentName = options.agentName; }
231
+ // Spec 2026-09-11 §4: the council's two agents ride into buildServerOptions.
232
+ if (options.agents) { serverOptions.agents = options.agents; }
260
233
  // Explicit per-call override only. Unset is the normal case and is correct:
261
234
  // buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
262
235
  // default downstream, so forwarding `undefined` here would change nothing.
@@ -42,7 +42,7 @@ function addAlias(name, modelString) {
42
42
  /**
43
43
  * Ensure a config exists with the chosen default model. Read-modify-write:
44
44
  * preserves every pre-existing top-level key (aliases, councils, …) and only
45
- * fills in the default + any missing default aliases. Never clobbers.
45
+ * fills in the default aliases are left untouched (absence follows, #238 Q9).
46
46
  * @param {string} defaultModel - Default model alias or full model string
47
47
  * @returns {object} The resulting config object
48
48
  */
@@ -51,17 +51,12 @@ function createDefaultConfig(defaultModel) {
51
51
  const cfg = {
52
52
  ...existing,
53
53
  default: existing.default || defaultModel,
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 || {}) },
54
+ // #238 Q9: no seeding. A curated alias FOLLOWS the shipped pin by being
55
+ // ABSENT from config.aliases (D1); only what the user chose is written.
56
+ aliases: { __proto__: null, ...(existing.aliases || {}) },
59
57
  };
60
58
  saveConfig(cfg);
61
- logger.info('Default config ensured', {
62
- default: cfg.default,
63
- aliasCount: Object.keys(cfg.aliases).length,
64
- });
59
+ logger.info('Default config ensured', { default: cfg.default, aliasCount: Object.keys(cfg.aliases).length });
65
60
  return cfg;
66
61
  }
67
62
 
@@ -524,7 +519,8 @@ async function runReadlineSetup() {
524
519
  return;
525
520
  }
526
521
 
527
- const { resolveQuickPicks, toLiveSeedAliases, toStorableRoute } = require('../utils/quick-picks');
522
+ const { resolveQuickPicks, toStorableRoute } = require('../utils/quick-picks');
523
+ const { stripGatewayPrefix } = require('../utils/curated-models');
528
524
  const picks = resolveQuickPicks(catalog);
529
525
 
530
526
  console.log('Choose your default model:');
@@ -545,7 +541,7 @@ async function runReadlineSetup() {
545
541
  }
546
542
 
547
543
  // Read-modify-write — never rebuild the alias table (no-clobber rule).
548
- const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
544
+ const cfg = loadConfig() || { aliases: {} };
549
545
  if (!cfg.aliases) { cfg.aliases = {}; }
550
546
  if (chosen.alias) {
551
547
  cfg.default = chosen.alias;
@@ -555,11 +551,16 @@ async function runReadlineSetup() {
555
551
  // pointing at that alias name is fine (the user's explicit overall-default
556
552
  // choice), but the alias's VALUE must stay the vendor phase's tier choice --
557
553
  // skip the curated-flagship upgrade so it isn't discarded.
554
+ // #238 Q9: write the chosen default's LIVE pick only when it differs
555
+ // from the shipped pin, and say so — a pin the user was told about.
556
+ // Otherwise leave the key alone: absent = follows (D1).
558
557
  if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
559
- cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
560
- } else if (cfg.aliases[chosen.alias] === undefined) {
561
- const fallback = getDefaultAliases()[chosen.alias];
562
- if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
558
+ const live = toStorableRoute(pick, { models: catalog, providerFailures });
559
+ const shipped = getDefaultAliases()[chosen.alias];
560
+ if (live && stripGatewayPrefix(live) !== stripGatewayPrefix(shipped)) {
561
+ cfg.aliases[chosen.alias] = live;
562
+ console.log(`${chosen.alias} → ${live} (live flagship differs from the shipped ${shipped} — pinned)`);
563
+ }
563
564
  }
564
565
 
565
566
  // #138: offer the family -> model second level. `pick.vendorPath` is
@@ -608,7 +609,7 @@ async function runReadlineSetup() {
608
609
  const shortlist = buildModelShortlist(pick.vendorPath, {
609
610
  catalog,
610
611
  providerFailures,
611
- recommendedId: cfg.aliases[chosen.alias],
612
+ recommendedId: cfg.aliases[chosen.alias] || getDefaultAliases()[chosen.alias],
612
613
  });
613
614
  const specific = await promptForVendorModel(
614
615
  askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
@@ -626,7 +627,8 @@ async function runReadlineSetup() {
626
627
 
627
628
  console.log('');
628
629
  console.log(`Default model set to: ${cfg.default}`);
629
- console.log(`Config saved (${Object.keys(cfg.aliases).length} aliases).`);
630
+ const pinned = Object.keys(cfg.aliases).length;
631
+ console.log(`Config saved (${pinned} pinned alias${pinned === 1 ? '' : 'es'}; the rest follow the shipped recommendations — amicus aliases).`);
630
632
  console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
631
633
 
632
634
  // C8: compact doctor summary, best-effort (see printDoctorFinale).
@@ -6,7 +6,7 @@
6
6
  * OpenCode Native Agents (https://opencode.ai/docs/agents/):
7
7
  * - Chat: Reads auto, writes/bash ask permission (interactive default)
8
8
  * - Build: Default primary agent with full tool access
9
- * - Plan: Read-only agent for analysis and planning
9
+ * - Plan: Analysis and planning without edits (reads, searches and shell allowed)
10
10
  * - General: Full-access agent for research
11
11
  * - Explore: Read-only agent for codebase exploration
12
12
  *
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @module utils/alias-groups
3
+ * Vendor-derived alias grouping (issue 213). Moved VERBATIM out of
4
+ * `electron/setup-ui-alias-groups.js` (#238 PR1 fix wave F5): `src/` code
5
+ * (src/sidecar/aliases.js — the CLI `amicus aliases` list) must not require
6
+ * from `electron/`, a layering violation the whole-branch review caught.
7
+ * `electron/setup-ui-alias-groups.js` re-exports every symbol here so its
8
+ * own callers (setup-ui-aliases.js, setup-ui.js) and their tests keep
9
+ * working untouched.
10
+ *
11
+ * REUSE NOTE: the vendor parse is `vendorOf` from src/sidecar/fallback-chains.js
12
+ * — the existing primitive, imported, not re-implemented. It PARSES a vendor
13
+ * segment (it never emits an id that gets called), which is the same
14
+ * ban-exempt category as the other allowlisted `vendorOf` callers in
15
+ * .eslintrc.js. `groupModelsByFamily` (src/utils/model-fetcher.js) is
16
+ * deliberately NOT reused: it keys on `id.split('/')[0]`, so every
17
+ * `openrouter/...` alias would collapse into a single "OpenRouter" bucket —
18
+ * exactly the grouping this file exists to avoid. Its DISPLAY half
19
+ * (PROVIDER_FAMILY_NAMES) is reused below.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { vendorOf } = require('../sidecar/fallback-chains');
25
+ const { PROVIDER_FAMILY_NAMES, listDirectProviders } = require('./provider-registry');
26
+
27
+ /**
28
+ * Display names for vendors seen in alias routes.
29
+ *
30
+ * DISPLAY ONLY — deliberately not folded into provider-registry's PROVIDERS,
31
+ * which is a *capability* registry (env var, direct-vs-gateway, live fetch).
32
+ * KNOWN_PROVIDERS / PROVIDER_ENV_MAP are derived from that list, so adding
33
+ * `z-ai` there would claim Amicus can hold a z-ai API key. The five real
34
+ * providers keep their single source of truth via PROVIDER_FAMILY_NAMES.
35
+ * Module-private: nothing outside this file requires it directly.
36
+ */
37
+ const ALIAS_VENDOR_LABELS = {
38
+ ...PROVIDER_FAMILY_NAMES,
39
+ // Vendors reachable through the gateway (curated + commonly pinned)
40
+ 'qwen': 'Qwen',
41
+ 'mistralai': 'Mistral AI',
42
+ 'z-ai': 'Z.AI',
43
+ 'minimax': 'MiniMax',
44
+ 'x-ai': 'xAI',
45
+ 'moonshotai': 'Moonshot AI',
46
+ 'bytedance-seed': 'ByteDance Seed',
47
+ 'thinkingmachines': 'Thinking Machines',
48
+ 'cognitivecomputations': 'Cognitive Computations',
49
+ 'inclusionai': 'InclusionAI',
50
+ 'nvidia': 'NVIDIA',
51
+ 'cohere': 'Cohere',
52
+ 'meta-llama': 'Meta Llama',
53
+ 'nousresearch': 'Nous Research',
54
+ 'perplexity': 'Perplexity',
55
+ 'microsoft': 'Microsoft',
56
+ 'ai21': 'AI21',
57
+ 'amazon': 'Amazon',
58
+ // Local providers (src/utils/local-providers.js PRESETS / VALID_FLAVORS)
59
+ 'ollama': 'Ollama',
60
+ 'lmstudio': 'LM Studio',
61
+ 'vllm': 'vLLM',
62
+ };
63
+
64
+ /**
65
+ * `some-new-vendor` -> `Some New Vendor`, so an unmapped vendor is not a raw slug.
66
+ * @param {string} vendor
67
+ * @returns {string}
68
+ */
69
+ function titleCaseVendor(vendor) {
70
+ return String(vendor).split(/[-_]/).filter(Boolean)
71
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
72
+ }
73
+
74
+ /**
75
+ * Vendor key for an alias route. Wraps the shared `vendorOf` with the two
76
+ * normalisations issue 213 flagged: case, and the leading `~` of a floating
77
+ * OpenRouter id (`openrouter/~z-ai/glm-latest` must not form a second group
78
+ * next to `z-ai`).
79
+ * @param {string} route @returns {string} '' when there is no usable route
80
+ */
81
+ function aliasVendorOf(route) {
82
+ const v = vendorOf(route).toLowerCase();
83
+ return v.charAt(0) === '~' ? v.slice(1) : v;
84
+ }
85
+
86
+ /**
87
+ * Display label for a vendor key.
88
+ * hasOwnProperty, not a bare lookup: vendor is derived from a user-editable
89
+ * route, and `__proto__`/`constructor` would otherwise return prototype junk.
90
+ * @param {string} vendor @returns {string}
91
+ */
92
+ function vendorLabel(vendor) {
93
+ if (!vendor) { return 'Other'; }
94
+ const hit = Object.prototype.hasOwnProperty.call(ALIAS_VENDOR_LABELS, vendor)
95
+ ? ALIAS_VENDOR_LABELS[vendor] : null;
96
+ return hit || titleCaseVendor(vendor);
97
+ }
98
+
99
+ /** Direct-route vendors render first; everything else sorts by label. @type {string[]} */
100
+ const PREFERRED_VENDOR_ORDER = listDirectProviders();
101
+
102
+ /**
103
+ * Bucket an alias map by route vendor.
104
+ * INVARIANT: every own key of `aliases` lands in exactly one returned group —
105
+ * there is no whitelist to miss, and the empty vendor is a real catch-all.
106
+ * Order within a group follows the config's own key order.
107
+ * @param {Object<string,string>} aliases
108
+ * @returns {Array<{vendor: string, label: string, keys: string[]}>}
109
+ */
110
+ function groupAliases(aliases) {
111
+ const byVendor = new Map();
112
+ for (const key of Object.keys(aliases || {})) {
113
+ const vendor = aliasVendorOf(aliases[key]);
114
+ if (!byVendor.has(vendor)) { byVendor.set(vendor, []); }
115
+ byVendor.get(vendor).push(key);
116
+ }
117
+ const rank = (vendor) => {
118
+ if (!vendor) { return Number.MAX_SAFE_INTEGER; } // catch-all group last
119
+ const i = PREFERRED_VENDOR_ORDER.indexOf(vendor);
120
+ return i === -1 ? PREFERRED_VENDOR_ORDER.length : i;
121
+ };
122
+ return Array.from(byVendor.entries())
123
+ .map(([vendor, keys]) => ({ vendor, label: vendorLabel(vendor), keys }))
124
+ .sort((a, b) => rank(a.vendor) - rank(b.vendor) ||
125
+ a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
126
+ }
127
+
128
+ module.exports = { groupAliases, aliasVendorOf, vendorLabel, titleCaseVendor, PREFERRED_VENDOR_ORDER };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @module utils/alias-proposals
3
+ * The alias review ENGINE (#238 §2): pure, no I/O, no prompts. Turns
4
+ * (config.aliases, DEFAULT_ALIASES, catalogInfo, dismissed, retired, notable)
5
+ * into ONE proposal per alias (Q4), for the CLI picker and the Electron
6
+ * "Needs review" section to render and for their sinks to write.
7
+ *
8
+ * Only PINNED aliases propose (D1): a following alias resolves to the shipped
9
+ * pin and cannot drift. The §5 DISPLAY gate is applied here: candidates never
10
+ * come from a non-authoritative row or a rejected namespace, and a sibling
11
+ * (model-id-siblings.js) is always strictly newer. A stale pin's
12
+ * `replacement` candidates are ranked by similarity (not recency) and sit
13
+ * after `follow` for a curated alias. A replacement never repeats an id
14
+ * already listed as `follow`. The WRITE gate (a fresh catalog) is the
15
+ * renderer's, at accept time.
16
+ *
17
+ * Own keys only: a `__proto__`/`toString` alias is a custom row here as it is
18
+ * everywhere else in the alias tables.
19
+ *
20
+ * `retired` and `notable` are inputs `amicus aliases` does not supply yet:
21
+ * Phase 2 (`curated-pins.json`) ships the retirement data and Phase 4 the
22
+ * notable list (spec §7); the paths are built and tested here so they are
23
+ * not written twice, and are inert from the CLI until then (council r2, D3).
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const { listAliasRows } = require('./alias-state');
29
+ const { newestSibling } = require('./model-id-siblings');
30
+ const { findStaleAliases, suggestReplacements } = require('./alias-audit');
31
+ const { stripGatewayPrefix } = require('./curated-models');
32
+
33
+ const own = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
34
+ const providerOf = (id) => (typeof id === 'string' ? id.split('/')[0] : '');
35
+ const sameModel = (a, b) => typeof a === 'string' && typeof b === 'string' && stripGatewayPrefix(a) === stripGatewayPrefix(b);
36
+
37
+ /** §5 rules 1–2: rows a proposal may name. */
38
+ function candidateRows(models, failures) {
39
+ return models.filter(m => m && typeof m.id === 'string' && m.authoritative !== false && !failures.has(providerOf(m.id)));
40
+ }
41
+
42
+ /**
43
+ * The §5 display gate's id set, exposed for the picker (#249 r1 R2): a typed
44
+ * "choose another" id that names a real catalog row must still fail if that
45
+ * row is a floor entry (`authoritative: false`) or sits in a rejected
46
+ * namespace -- the numbered menu would never have offered it as a candidate
47
+ * either. Reuses `candidateRows` so the two paths can never disagree.
48
+ * @param {{models?: Array, providerFailures?: Array}|null} catalogInfo
49
+ * @returns {string[]} ids of every row the §5 display gate allows as a candidate
50
+ */
51
+ function gatedCatalogIds(catalogInfo) {
52
+ if (!catalogInfo || typeof catalogInfo !== 'object') { return []; }
53
+ const models = Array.isArray(catalogInfo.models) ? catalogInfo.models : [];
54
+ const rawFailures = catalogInfo.providerFailures;
55
+ const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
56
+ return candidateRows(models, failures).map(m => m.id);
57
+ }
58
+
59
+ function proposeForRow(r, ctx) {
60
+ if (r.state !== 'pinned' || own(ctx.retired, r.alias)) { return null; }
61
+ if (ctx.failures.has(providerOf(r.id))) { return null; } // its namespace cannot be judged
62
+ // Staleness is judged against the RAW catalog on purpose (council r2,
63
+ // B1/D4): the §5 gate governs what is PROPOSED, not what is condemned --
64
+ // on a keyless machine the only Anthropic rows are the hardcoded floor,
65
+ // and judging liveness against the gated set would call every working
66
+ // Anthropic pin stale and propose a replacement for each. A floor row is
67
+ // not evidence the provider serves a model; it is not evidence it
68
+ // stopped, either.
69
+ const stale = findStaleAliases([{ alias: r.alias, model: r.id, source: 'user-config' }], ctx.models).length === 1;
70
+ const sibling = newestSibling(r.id, ctx.candidateIds);
71
+ // Order (fix round 1, Finding 3): sibling first, UNLESS it is the shipped
72
+ // model itself — `follow` already names that id, so listing it twice as
73
+ // both "newer-sibling" and "follow" would be a display duplicate. `follow`
74
+ // outranks similarity-ranked replacements: a human-curated shipped pin is
75
+ // a better answer than a same-vendor guess, and a sibling was already
76
+ // found (even if hidden here as identical to `follow`) means the
77
+ // structurally-aware comparator has already answered "what's newer" —
78
+ // replacements are only offered when that comparator found nothing at all.
79
+ const differs = r.curated && !sameModel(r.shipped, r.id);
80
+ // F2: a sibling identical to the shipped id is not a DISTINCT candidate (it
81
+ // never reaches `candidates` below), so it must not be named in `reasons`
82
+ // either — reasons describes what was actually offered, not every signal
83
+ // the engine looked at.
84
+ const siblingIsCandidate = !!sibling && !sameModel(sibling, r.shipped);
85
+ const reasons = [];
86
+ if (stale) { reasons.push('stale'); }
87
+ if (siblingIsCandidate) { reasons.push('newer-sibling'); }
88
+ if (differs) { reasons.push('differs-from-shipped'); }
89
+ if (reasons.length === 0) { return null; }
90
+ const candidates = [];
91
+ if (siblingIsCandidate) { candidates.push({ id: sibling, why: 'newer-sibling', evidence: {} }); }
92
+ if (differs) { candidates.push({ id: r.shipped, why: 'follow', evidence: {} }); }
93
+ if (stale && !sibling) {
94
+ // Fix round 2 (ruling: DEDUPE): a replacement never repeats an id
95
+ // already listed (in practice, `follow`'s) — the same id under two
96
+ // rationales reads as a picker bug. `suggestReplacements` still caps its
97
+ // OWN output at up to 3; dropping a duplicate here can leave fewer than
98
+ // 3, never more (no backfill).
99
+ const seen = new Set(candidates.map(c => c.id));
100
+ for (const id of suggestReplacements(r.id, ctx.candidates)) {
101
+ if (!seen.has(id)) { candidates.push({ id, why: 'replacement', evidence: {} }); }
102
+ }
103
+ }
104
+ const dismissKey = `${r.alias}@${candidates.length ? candidates[0].id : r.id}`;
105
+ if (own(ctx.dismissed, dismissKey)) { return null; }
106
+ return { alias: r.alias, state: 'pinned', current: r.id, shipped: r.shipped, curated: r.curated, reasons, candidates, dismissKey };
107
+ }
108
+
109
+ function proposeNotable(entry, ctx) {
110
+ if (!entry || typeof entry.id !== 'string' || typeof entry.suggestedAlias !== 'string') { return null; }
111
+ if (own(ctx.retired, entry.suggestedAlias) || !ctx.candidateIds.includes(entry.id)) { return null; }
112
+ // Fix round 1, Finding 1: a notable must not shadow an alias NAME that
113
+ // already exists (curated or user, pinned or following) — rule 10 checked
114
+ // only the model, not the name, so a notable naming an already-pinned or
115
+ // already-taken alias produced a second, conflicting proposal for the same
116
+ // name (an 'unmapped' one beside the real 'pinned'/'following' one, in the
117
+ // worst case sharing a dismissKey with it).
118
+ if (ctx.names.has(entry.suggestedAlias)) { return null; }
119
+ if (ctx.mapped.some(id => sameModel(id, entry.id))) { return null; }
120
+ const dismissKey = `${entry.suggestedAlias}@${entry.id}`;
121
+ if (own(ctx.dismissed, dismissKey)) { return null; }
122
+ return { alias: entry.suggestedAlias, state: 'unmapped', current: null, shipped: null, curated: false,
123
+ reasons: ['notable-unmapped'], candidates: [{ id: entry.id, why: 'notable', evidence: { note: entry.note || '' } }], dismissKey };
124
+ }
125
+
126
+ /**
127
+ * @param {{userAliases: object|null, defaults: object, catalogInfo: {models?: Array, providerFailures?: Array}|null,
128
+ * retired?: object, notable?: Array<{id:string, suggestedAlias:string, note?:string}>, dismissed?: object}} input
129
+ * @returns {Array<object>} proposals — see the module docblock for the shape
130
+ */
131
+ function buildAliasProposals({ userAliases, defaults, catalogInfo, retired = {}, notable = [], dismissed = {} }) {
132
+ const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
133
+ if (models.length === 0 || !defaults) { return []; }
134
+ // Fix round 1, Finding 2 (rule 11 "never throws on odd input"): a truthy
135
+ // non-array providerFailures (e.g. `{}`) must not reach `.map`.
136
+ const rawFailures = catalogInfo.providerFailures;
137
+ const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
138
+ const candidates = candidateRows(models, failures);
139
+ const rows = listAliasRows(userAliases, defaults);
140
+ const ctx = {
141
+ models, failures, candidates, candidateIds: candidates.map(m => m.id),
142
+ retired: retired || {}, dismissed: dismissed || {},
143
+ mapped: rows.map(r => r.id), names: new Set(rows.map(r => r.alias)),
144
+ };
145
+ const out = [];
146
+ for (const r of rows) { const p = proposeForRow(r, ctx); if (p) { out.push(p); } }
147
+ for (const n of (Array.isArray(notable) ? notable : [])) { const p = proposeNotable(n, ctx); if (p) { out.push(p); } }
148
+ return out;
149
+ }
150
+
151
+ module.exports = { buildAliasProposals, gatedCatalogIds };
@@ -39,7 +39,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
39
39
  }
40
40
  throw new Error(
41
41
  `Alias '${alias}' is configured but has no model value. ` +
42
- `Fix with: amicus setup --add-alias ${alias}=provider/model`
42
+ `Fix with: amicus aliases --review, or amicus setup --add-alias ${alias}=provider/model`
43
43
  );
44
44
  }
45
45
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module utils/alias-state
3
+ * Following-vs-pinned state for model aliases (#238 D1) and the normalization
4
+ * that keeps config.json truthful (D6).
5
+ *
6
+ * A curated alias FOLLOWS the shipped pin when its name is ABSENT from
7
+ * `config.aliases` — `config.js :: getEffectiveAliases` already merges
8
+ * `{...DEFAULT_ALIASES, ...userAliases}`, so absence resolves to the shipped
9
+ * id on every consumer. A present key is a PIN. Downgrade-safe in the narrow
10
+ * sense: an older amicus reads a normalized config without error and
11
+ * honours every present key (a pin) unchanged; the aliases that FOLLOW
12
+ * resolve to that older binary's shipped pins — following means tracking
13
+ * whichever binary runs.
14
+ *
15
+ * Normalization drops any key whose value equals the shipped default, with one
16
+ * Notice per key. It runs inside `saveConfig` (so every write converges) and on
17
+ * entry to `amicus aliases`. It is idempotent and never a startup write.
18
+ *
19
+ * Own keys only, everywhere: a `toString`/`constructor` name in a user
20
+ * config is a plain custom alias, never a curated one; a `__proto__` key
21
+ * gets no row at all — `saveConfig` can never persist it.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
27
+
28
+ /** @param {string} alias @param {object} defaults @returns {boolean} */
29
+ function isCurated(alias, defaults) {
30
+ return !!defaults && typeof alias === 'string' && own(defaults, alias);
31
+ }
32
+
33
+ /**
34
+ * @param {object} aliases user `config.aliases`
35
+ * @param {object} defaults the shipped map (`DEFAULT_ALIASES`)
36
+ * @param {(line: string) => void} [notify] one call per removed key
37
+ * @returns {{aliases: object, removed: Array<{alias: string, id: string}>}}
38
+ */
39
+ function normalizeAliases(aliases, defaults, notify) {
40
+ // Null-prototype (not plain `{}`): `out['__proto__'] = value` on a plain
41
+ // object hits the inherited accessor setter and is silently LOST -- the
42
+ // same footgun `saveConfig`'s pre-existing stripper documents for `cleaned`.
43
+ // Not reachable from `saveConfig` today (that stripper rejects `__proto__`
44
+ // before this function ever sees it), but this module is also entered
45
+ // directly from `amicus aliases` on raw config, so `out` must be safe on
46
+ // its own. `JSON.stringify` serializes a null-prototype object's own keys
47
+ // exactly like a plain one, so returning it as-is is safe for saveConfig.
48
+ const out = { __proto__: null };
49
+ const removed = [];
50
+ if (!aliases || typeof aliases !== 'object') { return { aliases: out, removed }; }
51
+ for (const [alias, value] of Object.entries(aliases)) {
52
+ if (typeof value === 'string' && isCurated(alias, defaults) && defaults[alias] === value) {
53
+ removed.push({ alias, id: value });
54
+ if (typeof notify === 'function') {
55
+ notify(`Notice: alias '${alias}' matches the shipped recommendation (${value}) — now following\n`);
56
+ }
57
+ continue;
58
+ }
59
+ out[alias] = value;
60
+ }
61
+ return { aliases: out, removed };
62
+ }
63
+
64
+ /**
65
+ * @param {object|null} userAliases
66
+ * @param {object} defaults
67
+ * @returns {Array<{alias:string,id:string,state:'following'|'pinned',curated:boolean,shipped:string|null}>}
68
+ */
69
+ function listAliasRows(userAliases, defaults) {
70
+ const user = (userAliases && typeof userAliases === 'object') ? userAliases : {};
71
+ const rows = [];
72
+ for (const alias of Object.keys(defaults || {})) {
73
+ const pinned = own(user, alias) && typeof user[alias] === 'string';
74
+ rows.push({ alias, id: pinned ? user[alias] : defaults[alias], state: pinned ? 'pinned' : 'following',
75
+ curated: true, shipped: defaults[alias] });
76
+ }
77
+ for (const alias of Object.keys(user)) {
78
+ // #249 r1 R8b: '__proto__' can never be persisted (saveConfig's own
79
+ // stripper rejects it, config.js :: saveConfig) -- a row for it here
80
+ // would show state the user can never actually reach, so it gets no
81
+ // row and no proposal.
82
+ if (alias === '__proto__' || isCurated(alias, defaults) || typeof user[alias] !== 'string') { continue; }
83
+ rows.push({ alias, id: user[alias], state: 'pinned', curated: false, shipped: null });
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ module.exports = { normalizeAliases, listAliasRows, isCurated };