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
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @module utils/alias-store
3
+ * The write sinks the alias review flow needs beyond `setup.js :: addAlias`
4
+ * (#238 §2). Every write is read-modify-write through `saveConfig` (which
5
+ * normalizes, D6) and preserves every other key — the no-clobber contract of
6
+ * `provider-default-picker.js :: applyProviderDefault`.
7
+ *
8
+ * `removeAlias` is BOTH "unpin" (a curated name resurrects from the defaults)
9
+ * and "delete" (a user-invented name is gone) — the meaning is decided by
10
+ * whether the name is curated, not by this module (D1).
11
+ *
12
+ * Dismissals are keyed `alias@proposedId` (Q5): permanent for that pair, and a
13
+ * newer proposed id for the same alias is a new key that asks again.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
19
+
20
+ /**
21
+ * @param {string} alias
22
+ * @returns {boolean} true when a key was removed and the config saved
23
+ */
24
+ function removeAlias(alias) {
25
+ if (typeof alias === 'string') { alias = alias.trim(); }
26
+ if (!alias || typeof alias !== 'string' || alias === 'null') {
27
+ throw new Error(`Invalid alias name: '${alias}'. Alias name must be a non-empty string.`);
28
+ }
29
+ const { loadConfig, saveConfig } = require('./config');
30
+ const config = loadConfig();
31
+ if (!config || !config.aliases || typeof config.aliases !== 'object' || !own(config.aliases, alias)) { return false; }
32
+ delete config.aliases[alias];
33
+ saveConfig(config);
34
+ return true;
35
+ }
36
+
37
+ /** @returns {Object<string,string>} null-prototype copy of aliasReview.dismissed */
38
+ function readDismissals() {
39
+ const { loadConfig } = require('./config');
40
+ const config = loadConfig();
41
+ const out = { __proto__: null };
42
+ const d = config && config.aliasReview && typeof config.aliasReview === 'object' ? config.aliasReview.dismissed : null;
43
+ if (d && typeof d === 'object') {
44
+ for (const [k, v] of Object.entries(d)) { if (typeof v === 'string') { out[k] = v; } }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * @param {string} dismissKey `alias@proposedId`
51
+ * @param {Date} [now]
52
+ */
53
+ function recordDismissal(dismissKey, now = new Date()) {
54
+ if (typeof dismissKey !== 'string' || !dismissKey || !dismissKey.includes('@')) {
55
+ throw new Error(`Invalid dismissKey '${dismissKey}': expected alias@proposedId`);
56
+ }
57
+ const { loadConfig, saveConfig } = require('./config');
58
+ const config = loadConfig() || {};
59
+ if (!config.aliasReview || typeof config.aliasReview !== 'object') { config.aliasReview = {}; }
60
+ if (!config.aliasReview.dismissed || typeof config.aliasReview.dismissed !== 'object') { config.aliasReview.dismissed = {}; }
61
+ config.aliasReview.dismissed[dismissKey] = now.toISOString();
62
+ saveConfig(config);
63
+ }
64
+
65
+ module.exports = { removeAlias, readDismissals, recordDismissal };
@@ -77,7 +77,10 @@ function saveConfig(configData) {
77
77
  }
78
78
  cleaned[key] = value;
79
79
  }
80
- configData.aliases = cleaned;
80
+ // #238 D6: a key equal to the shipped default is the same as absence —
81
+ // drop it so the alias FOLLOWS the next pin bump (one Notice per key).
82
+ const { normalizeAliases } = require('./alias-state');
83
+ configData.aliases = normalizeAliases(cleaned, DEFAULT_ALIASES, (line) => process.stderr.write(line)).aliases;
81
84
  }
82
85
  const configDir = getConfigDir();
83
86
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -187,9 +190,11 @@ function computeConfigHash() {
187
190
  /** @returns {string} Markdown alias table with (default) marker, or empty string */
188
191
  function buildAliasTable() {
189
192
  const config = loadConfig();
190
- if (!config || !config.aliases || Object.keys(config.aliases).length === 0) {
191
- return '';
192
- }
193
+ if (!config) { return ''; }
194
+ // #238 D6: EFFECTIVE aliases — a following alias is absent from config.aliases
195
+ // but is still an alias the project's CLAUDE.md block must list.
196
+ const aliases = getEffectiveAliases();
197
+ if (Object.keys(aliases).length === 0) { return ''; }
193
198
 
194
199
  const defaultAlias = config.default || null;
195
200
  const lines = [];
@@ -197,7 +202,7 @@ function buildAliasTable() {
197
202
  lines.push('| Alias | Model |');
198
203
  lines.push('|-------|-------|');
199
204
 
200
- for (const [alias, model] of Object.entries(config.aliases)) {
205
+ for (const [alias, model] of Object.entries(aliases)) {
201
206
  const marker = (alias === defaultAlias) ? ' (default)' : '';
202
207
  lines.push(`| ${alias}${marker} | ${model} |`);
203
208
  }
@@ -39,6 +39,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
39
39
  // lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
40
40
  // already gates the Stage-2 notes out of `seat-unbound` for the same reason).
41
41
  'stage2-judge',
42
+ // #242 / spec §5 (v4.9.8): render-time rows the report derives from runStats
43
+ // (council/report-lost-rows.js) — never emitted by the sink, so neither can
44
+ // flip `degraded` or the exit code. `unverified-repair`: a seat's findings
45
+ // came from a repair of a response with no findings block
46
+ // (runStats[].findingsUnverified); `repair-refused`: the repair broke its
47
+ // count contract (runStats[].repairRefused). Registered here because the
48
+ // degrade-contract drift pin reads every `channel:` literal in src/.
49
+ 'unverified-repair', 'repair-refused',
42
50
  'internal',
43
51
  // doctor channels
44
52
  'doctor-check-failed', 'doctor-fix',
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @module utils/model-id-siblings
3
+ * The tier-safe sibling comparator (#238 D7). A sibling shares a pin's vendor
4
+ * path, its pre-version prefix and its post-version suffix, and differs only in
5
+ * the version number — so `gpt-5.6-terra` is never compared against
6
+ * `gpt-5.6-sol`, and `kimi-k3` never against `kimi-k2.7-code`. Lifted verbatim
7
+ * from scripts/check-ci-alias-pins.js (which still consumes it) so the alias
8
+ * review engine (alias-proposals.js) asks the user's pinned ids the SAME sibling
9
+ * question the CI drift gate asks the CI alias map. `amicus models --check`
10
+ * asks a DIFFERENT question of the same shipped pins — still-listed vs.
11
+ * gone-from-the-catalog — through `alias-audit.js :: findStaleAliases`, not
12
+ * this module (#249 r2 review F2: this line previously implied otherwise).
13
+ *
14
+ * Known limits, both under-report by design (a suppressed sibling, never a
15
+ * manufactured one) — INVARIANT: neither rule can ever turn two DIFFERENT
16
+ * models into siblings, only fail to notice two of the SAME family are:
17
+ * 1. A dash-versioned id — Anthropic's `claude-opus-4-5`, say — parses its
18
+ * trailing `-5` as part of the suffix rather than the version (the
19
+ * version group only extends through a DOTTED numeric run), so two
20
+ * dash-versioned releases are never compared as siblings at all.
21
+ * 2. A numeric run glued to a following ASCII letter is a size/variant
22
+ * token, never a version (#249 r2 B2) — `parsePin` returns `null` for
23
+ * both `gpt-oss-20b` and `gpt-oss-120b` (#249 r2 review F6: a null
24
+ * parse has no prefix at all, unlike limit 1's dash-versioned case
25
+ * above), since `20`/`120` are each immediately followed by `b` and no
26
+ * other numeric-dotted run exists to fall back to. MEASURED against a
27
+ * 638-id live catalog cache: 129 ids carry a glued run (`24b`, `70b`,
28
+ * `a3b`, `8x22b`, `4o`, …) — without this rule `gpt-oss-120b` reads as
29
+ * a "newer same-tier sibling" of `gpt-oss-20b`, a different model
30
+ * wearing a bigger size, not a newer version. A run preceded by a
31
+ * letter is unaffected (`kimi-k3`, `deepseek-v4-pro`, `qwen3.8-max`
32
+ * keep parsing exactly as before) — only the character AFTER the run
33
+ * is examined.
34
+ * `scripts/check-ci-alias-pins.js`'s CI drift gate consumes `parsePin`
35
+ * unchanged, so it inherits rule 2 automatically.
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ /**
41
+ * Split a model id into the parts a sibling comparison needs.
42
+ * `openrouter/z-ai/glm-5.3` -> vendor `openrouter/z-ai`, prefix `glm-`,
43
+ * version [5,3], suffix ``. Returns null when the tail carries no numeric
44
+ * version (nothing to compare) or the id is an OpenRouter floating pointer,
45
+ * whose `~` forms name no concrete release.
46
+ * @param {string} id
47
+ * @returns {{vendor:string, prefix:string, version:number[], suffix:string}|null}
48
+ */
49
+ function parsePin(id) {
50
+ if (typeof id !== 'string' || id.includes('~')) { return null; }
51
+ const cut = id.lastIndexOf('/');
52
+ if (cut === -1) { return null; }
53
+ const vendor = id.slice(0, cut);
54
+ const tail = id.slice(cut + 1);
55
+ // Scan every numeric-dotted run left to right; the first one NOT glued to
56
+ // a following ASCII letter is the version (#249 r2 B2 -- see the module
57
+ // docblock's known limit 2). A glued run is a size/variant token (`20b`,
58
+ // `8x22b`) and is skipped, falling through to whatever comes after it --
59
+ // which, once a version is found, is everything the plain suffix already
60
+ // captured, glued runs included.
61
+ for (const m of tail.matchAll(/\d+(?:\.\d+)*/g)) {
62
+ const end = m.index + m[0].length;
63
+ if (/[A-Za-z]/.test(tail[end] || '')) { continue; }
64
+ return { vendor, prefix: tail.slice(0, m.index), version: m[0].split('.').map(Number), suffix: tail.slice(end) };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** @returns {number} >0 when a is newer than b, <0 when older, 0 when equal */
70
+ function compareVersions(a, b) {
71
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
72
+ const diff = (a[i] || 0) - (b[i] || 0);
73
+ if (diff !== 0) { return diff; }
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ /**
79
+ * @param {string} pinned a fully-qualified model id
80
+ * @param {string[]} catalogIds every id in the live catalog
81
+ * @returns {string|null} the newest strictly-newer sibling, or null
82
+ */
83
+ function newestSibling(pinned, catalogIds) {
84
+ const pin = parsePin(pinned);
85
+ if (!pin) { return null; }
86
+ let best = null;
87
+ let bestVersion = pin.version;
88
+ for (const id of catalogIds) {
89
+ // No blanket `:` skip. `:free` / `:batch` land in `suffix`, so the
90
+ // suffix equality below ALREADY refuses to bump a plain pin to a billing
91
+ // variant — while a blanket skip additionally blinded the checker to a
92
+ // pin that is ITSELF a variant (a `:free` pin could never find a `:free`
93
+ // sibling, and went quietly unwatched forever). Council finding D3.
94
+ const other = parsePin(id);
95
+ if (!other) { continue; }
96
+ if (other.vendor !== pin.vendor) { continue; }
97
+ if (other.prefix !== pin.prefix || other.suffix !== pin.suffix) { continue; }
98
+ if (compareVersions(other.version, bestVersion) > 0) {
99
+ best = id;
100
+ bestVersion = other.version;
101
+ }
102
+ }
103
+ return best;
104
+ }
105
+
106
+ module.exports = { parsePin, compareVersions, newestSibling };
@@ -159,7 +159,7 @@ async function validateAgainstCatalog(resolvedModel, alias) {
159
159
  throw new Error(
160
160
  `Model '${resolvedModel}' not found in the OpenRouter catalog.\n` +
161
161
  (list ? `Did you mean:\n${list}\n` : '') +
162
- `Fix: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'}\n` +
162
+ `Fix: amicus aliases --review (or pin directly: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'})\n` +
163
163
  'Run \'amicus models --refresh\' to update the catalog, or pass --no-validate-model to skip.'
164
164
  );
165
165
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases, DIVERGENT_VENDORS } = require('./curated-models');
13
+ const { getFamilies, DIVERGENT_VENDORS } = require('./curated-models');
14
14
  const { directFormIfSafe } = require('./model-canonicalization');
15
15
 
16
16
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
@@ -37,7 +37,11 @@ function compareIdsDesc(a, b) {
37
37
  function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
38
38
  const prefix = `${nsPrefix}${vendorPath}/`;
39
39
  const ids = (Array.isArray(catalog) ? catalog : [])
40
- .map(m => m && m.id)
40
+ // #238 §5: a non-authoritative row (the hardcoded Anthropic floor, a
41
+ // floor-fallback) is not evidence of what the provider serves — the same
42
+ // guard gateway-route-audit.js :: isAuthoritative applies per gateway.
43
+ .filter(m => m && m.authoritative !== false)
44
+ .map(m => m.id)
41
45
  .filter(id => typeof id === 'string' && id.startsWith(prefix))
42
46
  .filter(id => idPattern.test(id.slice(prefix.length)));
43
47
  if (ids.length === 0) { return null; }
@@ -92,38 +96,15 @@ function toStorableRoute(pick, catalogInfo) {
92
96
  }
93
97
  const route = routes.openrouter || Object.values(routes)[0];
94
98
  if (!route) { return undefined; }
95
- // issue 214 remedy 1: this value is PERSISTED (sidecar/setup.js writes it into
96
- // config.aliases; toLiveSeedAliases seeds a fresh config with it), so it must
97
- // not be a blind prefix strip. directFormIfSafe keeps the optimism for a
98
- // namespace that was never fetched while refusing for one the catalog
99
- // disproves OR whose fetch was rejected -- the gap #208 closed on the picker
100
- // path and left open here.
99
+ // issue 214 remedy 1: this value is PERSISTED -- sidecar/setup.js writes it
100
+ // into config.aliases for the chosen default when it differs from the
101
+ // shipped pin (#238 Q9) -- so it must not be a blind prefix strip.
102
+ // directFormIfSafe keeps the optimism for a namespace that was never
103
+ // fetched while refusing for one the catalog disproves OR whose fetch was
104
+ // rejected -- the gap #208 closed on the picker path and left open here.
101
105
  return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
102
106
  }
103
107
 
104
- /**
105
- * Seed map for fresh configs: static defaults overlaid with live family
106
- * routes (cardless aliases stay pinned). See `toStorableRoute` for why the
107
- * overlaid value is not a raw prefix strip.
108
- * @returns {Object<string,string>}
109
- */
110
- function toLiveSeedAliases(catalogOrInfo) {
111
- // Accepts the bare models array (historical callers) or a full catalogInfo.
112
- // issue 214: the evidence was always handed in and then discarded.
113
- const info = Array.isArray(catalogOrInfo)
114
- ? { models: catalogOrInfo }
115
- : (catalogOrInfo || { models: [] });
116
- const seeds = toDefaultAliases();
117
- for (const r of resolveQuickPicks(info.models || [])) {
118
- if (r.source === 'live' && r.routes.openrouter) {
119
- const stored = toStorableRoute(r, info);
120
- if (stored) { seeds[r.alias] = stored; }
121
- }
122
- }
123
- return seeds;
124
- }
125
-
126
-
127
108
  /**
128
109
  * Per-provider SAFE storable form for a resolved quick pick (issue 214).
129
110
  *
@@ -147,4 +128,4 @@ function canonicalRoutesFor(pick, catalogInfo) {
147
128
  return out;
148
129
  }
149
130
 
150
- module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toLiveSeedAliases, toStorableRoute };
131
+ module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toStorableRoute };
@@ -20,6 +20,17 @@
20
20
  * that has landed here (ANSI in round 2, bidi in round 3) was a class the
21
21
  * previous pass could not see.
22
22
  *
23
+ * `safeFragment` (#249 r2 C4) is the same discipline for a shorter kind of
24
+ * text: not a sentence-length excerpt but ONE quoted config/catalog/typed
25
+ * value — an alias name, a model id, a dismiss key. MEASURED: the longest id
26
+ * in a 638-row live catalog cache is 67 characters
27
+ * (`openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition`);
28
+ * past 96 it is a payload, not an id. `utils/alias-shadow.js` keeps its OWN
29
+ * 64-char local cap (measured against the curated table alone, which never
30
+ * exceeds 39 characters) through this same `collapseExcerpt` function — one
31
+ * sanitizer, two caps sized to what each caller actually holds, no second
32
+ * dialect.
33
+ *
23
34
  * PURE: no I/O, no throwing paths, no state.
24
35
  */
25
36
 
@@ -75,7 +86,23 @@ function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
75
86
  return `${oneLine.slice(0, maxChars - 1)}…`;
76
87
  }
77
88
 
89
+ /** One quoted fragment's cap — an id/name/key, not a sentence. See the module docblock. */
90
+ const MAX_FRAGMENT_CHARS = 96;
91
+
92
+ /**
93
+ * One third-party fragment (an alias name, a model id, a dismiss key, a
94
+ * catalog note), safe to interpolate into a terminal line. Same pass as
95
+ * `collapseExcerpt`, just capped for a short value instead of a sentence.
96
+ * @param {*} value
97
+ * @returns {string}
98
+ */
99
+ function safeFragment(value) {
100
+ return collapseExcerpt(value, MAX_FRAGMENT_CHARS);
101
+ }
102
+
78
103
  module.exports = {
79
104
  collapseExcerpt,
80
105
  MAX_EXCERPT_CHARS,
106
+ safeFragment,
107
+ MAX_FRAGMENT_CHARS,
81
108
  };