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
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Direct-first id canonicalization, guarded by `classifyModel`
3
+ * (model-classification.js). Extracted out of `provider-default-picker.js`
4
+ * (issue 195 follow-up, F1/B4 -- council review of PR 198) purely to keep
5
+ * that file under the 300-line size gate; the two function bodies below are
6
+ * an unmodified move, not a rewrite.
7
+ *
8
+ * Two call sites, same predicate (`classifyModel`), opposite defaults --
9
+ * named separately on purpose so neither behavior can be reached by
10
+ * accident (e.g. forgetting to pass an option):
11
+ *
12
+ * - `directFormIfSafe` -- LIST-BUILDING (`chooseRowId`/`canonicalizeResolved`
13
+ * in provider-default-picker.js, building the picker's row list). With no
14
+ * catalog evidence either way, a bare policy-routed id is a reasonable
15
+ * GUESS to offer: optimistic, strips the `openrouter/` prefix unless
16
+ * `classifyModel` can prove the bare form `invalid`.
17
+ * - `directFormIfProven` -- PERSISTENCE (`applyProviderDefault`, writing
18
+ * `config.aliases[vendor]`). By the time an id is persisted, the caller
19
+ * has already handed in whatever prefix it decided on -- that prefix IS
20
+ * the user's choice, carrying information an empty or absent catalog does
21
+ * not contradict. Strips only on POSITIVE evidence (`classifyModel`
22
+ * returns `valid`, i.e. the bare id is an actual catalog row); anything
23
+ * else -- `unknown` (including a failed/absent catalog fetch, which is
24
+ * exactly the case that used to silently re-fabricate the id issue 195
25
+ * fixed) or `invalid` -- preserves the id exactly as given.
26
+ *
27
+ * Both gate `DIVERGENT_VENDORS` FIRST and internally, always returning the
28
+ * input unchanged for one of those vendors (dot vs. dash direct ids, e.g.
29
+ * anthropic) -- a caller cannot reach the optimistic OR the proven strip for
30
+ * a divergent vendor by forgetting to check the set itself.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
36
+ const { classifyModel } = require('./model-classification');
37
+
38
+ /**
39
+ * @param {string} vendor
40
+ * @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
41
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
42
+ * @returns {string} the bare direct id when not proven invalid, else `orId` unchanged
43
+ */
44
+ function directFormIfSafe(vendor, orId, catalogInfo) {
45
+ if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
46
+ const bare = toCanonicalDefault(orId);
47
+ if (bare === orId) { return orId; } // gateway-only vendor -- no direct integration at all
48
+ return classifyModel(bare, 'direct', catalogInfo) === 'invalid' ? orId : bare;
49
+ }
50
+
51
+ /**
52
+ * @param {string} vendor
53
+ * @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
54
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
55
+ * @returns {string} the bare direct id only when PROVEN valid, else `orId` unchanged
56
+ */
57
+ function directFormIfProven(vendor, orId, catalogInfo) {
58
+ if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
59
+ const bare = toCanonicalDefault(orId);
60
+ if (bare === orId) { return orId; }
61
+ return classifyModel(bare, 'direct', catalogInfo) === 'valid' ? bare : orId;
62
+ }
63
+
64
+ module.exports = { directFormIfSafe, directFormIfProven };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Vendor model shortlist (#138) -- the family -> model second level.
3
+ *
4
+ * Turns a vendor's deduped, priced picker rows into a
5
+ * `{suggested, rest}` split so a surface can show a short list without
6
+ * hiding anything: the GUI renders both groups in one <select>, readline
7
+ * prints `suggested` and offers `a` to print `rest` too.
8
+ *
9
+ * PURE and transport-agnostic, exactly like `provider-default-picker.js`:
10
+ * the catalog is always injected, and nothing here formats for a renderer.
11
+ *
12
+ * Why the caller supplies `recommendedId`: the picker's own preselect is
13
+ * COST-TIER driven (`computePreselectedId`), which disagrees with the
14
+ * wizard card's family flagship -- for DeepSeek, `v3.2` vs `v4-pro`.
15
+ * Owner ruling (#138, 2026-08-24) is that the flagship wins, so that
16
+ * opening the drill-down and accepting is a guaranteed no-op. The tier
17
+ * preselect remains the fallback when the flagship matches no row.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const { buildProviderDefaultChoices } = require('./provider-default-picker');
23
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
24
+
25
+ /** Rows shown before the "all models" group. */
26
+ const SHORTLIST_LIMIT = 8;
27
+
28
+ /**
29
+ * Both gateway spellings of a row id, so a surface can honour an explicit
30
+ * "via OpenRouter" choice for a drilled-down model. NEVER derives one form
31
+ * from the other -- `pairAcrossGateways` reads real catalog rows, which is
32
+ * the only safe move for DIVERGENT_VENDORS (anthropic's direct and
33
+ * OpenRouter ids are different strings, not differently prefixed).
34
+ * @param {string} vendor
35
+ * @param {string} id verbatim picker row id
36
+ * @param {Array<{id:string}>} catalog
37
+ * @returns {{directId: (string|null), openrouterId: (string|null)}}
38
+ */
39
+ function routeFormsFor(vendor, id, catalog) {
40
+ const token = id.replace(/^openrouter\//, '').replace(`${vendor}/`, '');
41
+ const paired = pairAcrossGateways(vendor, token, { models: catalog });
42
+ return {
43
+ directId: paired.direct || null,
44
+ openrouterId: paired.openrouter || null,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Recommended-first, then price-ascending, nulls last -- the same order
50
+ * `provider-default-picker.js`'s `compareRows` already establishes, so the
51
+ * drill-down reads like the picker the user may already have seen.
52
+ */
53
+ function compareShortlistRows(a, b) {
54
+ if (a.isRecommended !== b.isRecommended) { return a.isRecommended ? -1 : 1; }
55
+ if (a.pricePerMInput === null && b.pricePerMInput === null) { return 0; }
56
+ if (a.pricePerMInput === null) { return 1; }
57
+ if (b.pricePerMInput === null) { return -1; }
58
+ return a.pricePerMInput - b.pricePerMInput;
59
+ }
60
+
61
+ /**
62
+ * @param {string} vendor e.g. 'deepseek'
63
+ * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number}} [options]
64
+ * @returns {{recommendedId: (string|null), suggested: Array<object>,
65
+ * rest: Array<object>, total: number}}
66
+ */
67
+ function buildModelShortlist(vendor, options = {}) {
68
+ const catalog = Array.isArray(options.catalog) ? options.catalog : [];
69
+ const limit = Number.isInteger(options.limit) && options.limit > 0
70
+ ? options.limit : SHORTLIST_LIMIT;
71
+
72
+ const { preselectedId, rows } = buildProviderDefaultChoices(vendor, { catalog });
73
+ if (!rows || rows.length === 0) {
74
+ return { recommendedId: null, suggested: [], rest: [], total: 0 };
75
+ }
76
+
77
+ // Owner ruling: the family flagship wins when it names a real row;
78
+ // otherwise keep the picker's tier preselect rather than inventing one.
79
+ const wanted = options.recommendedId;
80
+ const recommendedId = (wanted && rows.some(r => r.id === wanted)) ? wanted : preselectedId;
81
+
82
+ const annotated = rows.map(r => Object.assign({
83
+ id: r.id,
84
+ name: r.name,
85
+ contextLength: r.contextLength,
86
+ pricePerMInput: r.pricePerMInput,
87
+ isRecommended: r.id === recommendedId,
88
+ }, routeFormsFor(vendor, r.id, catalog)));
89
+
90
+ annotated.sort(compareShortlistRows);
91
+
92
+ return {
93
+ recommendedId,
94
+ suggested: annotated.slice(0, limit),
95
+ rest: annotated.slice(limit),
96
+ total: annotated.length,
97
+ };
98
+ }
99
+
100
+ module.exports = { buildModelShortlist, compareShortlistRows, SHORTLIST_LIMIT };
@@ -20,7 +20,7 @@
20
20
 
21
21
  const { envNumber } = require('./env-num');
22
22
 
23
- const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 120000;
23
+ const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 300000;
24
24
 
25
25
  /** @param {object} [env] test seam; defaults to process.env */
26
26
  function resolveNoOutputBackstopMs(env) {
@@ -17,8 +17,11 @@
17
17
  const { resolveTier } = require('./model-tiers');
18
18
  const { getCostTier, loadConfig, saveConfig } = require('./config');
19
19
  const { pairAcrossGateways } = require('./gateway-route-catalog');
20
- const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
21
20
  const { isLocalProvider } = require('./local-providers');
21
+ // directFormIfSafe (list-building) / directFormIfProven (persistence): see
22
+ // model-canonicalization.js's module docstring for why these are two
23
+ // separately-named functions rather than one with an optional mode.
24
+ const { directFormIfSafe, directFormIfProven } = require('./model-canonicalization');
22
25
 
23
26
  /**
24
27
  * @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
@@ -49,8 +52,10 @@ function vendorRowsIn(catalog, vendor) {
49
52
  }
50
53
 
51
54
  /**
52
- * Choose the verbatim catalog id a single catalog `row` should surface as in
53
- * the picker -- NEVER fabricates an id.
55
+ * Choose the id a single catalog `row` should surface as in the picker.
56
+ * Prefers a real, verbatim catalog id; only SYNTHESISES one (delegating to
57
+ * `directFormIfSafe`, model-canonicalization.js -- see its module docstring
58
+ * for the guard) for a non-divergent vendor with no direct twin at all.
54
59
  *
55
60
  * A direct-namespace row always keeps its own (real, direct-callable) id,
56
61
  * regardless of what `pairAcrossGateways` could resolve for it -- this is
@@ -60,35 +65,55 @@ function vendorRowsIn(catalog, vendor) {
60
65
  * and must not be dropped or silently merged.
61
66
  *
62
67
  * An OpenRouter-namespace row is collapsed onto a direct id only when
63
- * `pairAcrossGateways` found exactly one (unambiguous) direct twin -- that's
64
- * a real catalog id, safe to reuse for dedup. Otherwise: for
68
+ * `pairAcrossGateways` found exactly one (unambiguous) direct twin. For
65
69
  * `DIVERGENT_VENDORS` (direct ids don't share a string form with
66
- * OpenRouter's -- e.g. anthropic's dash-vs-dot versioning), the row keeps
67
- * its OpenRouter-prefixed id as-is; stripping the prefix would fabricate a
68
- * non-direct-callable dot-form id no row actually carries. Non-divergent
69
- * vendors keep the pre-existing strip via `toCanonicalDefault`, which is
70
- * safe because their direct and OpenRouter ids are identical once the
71
- * `openrouter/<vendor>/` prefix is removed (mirrors curated-models.js's
72
- * `directFormFor` policy).
70
+ * OpenRouter's, e.g. anthropic's dash-vs-dot versioning), the row keeps its
71
+ * OpenRouter-prefixed id as-is -- `directFormIfSafe` gates that set FIRST
72
+ * and internally (model-canonicalization.js), so stripping is never even
73
+ * attempted on their ids, which is not a string-safe operation.
73
74
  * @param {string} vendor
74
75
  * @param {boolean} isDirect whether `row.id` is itself a direct-namespace id
75
76
  * @param {{id:string}} row
76
77
  * @param {{direct?:string, openrouter?:string}} paired
78
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
77
79
  * @returns {string|null}
78
80
  */
79
- function chooseRowId(vendor, isDirect, row, paired) {
81
+ function chooseRowId(vendor, isDirect, row, paired, catalogInfo) {
80
82
  if (isDirect) { return row.id; }
81
83
  if (paired.direct) { return paired.direct; }
82
84
  if (!paired.openrouter) { return null; }
83
- return DIVERGENT_VENDORS.has(vendor) ? paired.openrouter : toCanonicalDefault(paired.openrouter);
85
+ return directFormIfSafe(vendor, paired.openrouter, catalogInfo);
84
86
  }
85
87
 
86
88
  /**
87
89
  * Dedupe `vendor`'s catalog rows across gateways into one row per logical
88
90
  * model, reusing `pairAcrossGateways` (never re-deriving the pairing logic).
89
- * Every row id is verbatim from the catalog (see `chooseRowId`) -- never
90
- * fabricated, and a direct row is never dropped even when its OpenRouter
91
- * twin is ambiguous.
91
+ * A direct row is never dropped even when its OpenRouter twin is ambiguous.
92
+ *
93
+ * Row ids are verbatim catalog ids whenever a real row carries them: a
94
+ * direct row keeps its own id, and an OpenRouter row collapses onto a
95
+ * direct twin only when `pairAcrossGateways` found exactly one. For a
96
+ * NON-divergent vendor with no direct twin at all, `chooseRowId` derives
97
+ * the bare form via `directFormIfSafe` -- SYNTHESISED, not verbatim, only
98
+ * when the vendor's namespace can't prove it invalid (issue 195). Measured
99
+ * 2026-08-24 against a real 601-row catalog: all 14 `deepseek` rows derive
100
+ * this way (empty namespace); `google` (19/69) and `openai` (51/175) rows
101
+ * used to derive the same unconditional way despite a populated,
102
+ * authoritative namespace omitting that specific id, and now stay
103
+ * OpenRouter-prefixed instead. Once a bare id IS synthesized (the deepseek
104
+ * case), it is safe for routing, but not for the reason an earlier version
105
+ * of this comment claimed: a bare id is NOT failure-routed direct-first-
106
+ * with-OpenRouter-fallback. gateway-router.js's auto-mode step 7 is
107
+ * `if (rq.keys[vendor] && hasForm(rq, 'direct'))`, and `hasForm` is
108
+ * `!req.gatewayIds || req.gatewayIds[gateway] !== undefined` -- a bare id
109
+ * carries no `gatewayIds` at all, so `hasForm(rq, 'direct')` is VACUOUSLY
110
+ * true on this path; the only real gate is `rq.keys[vendor]`. The fallback
111
+ * to OpenRouter is therefore KEY-ABSENCE-driven (no key for `vendor`), not
112
+ * a check that the direct form actually resolves -- that check only fires
113
+ * on the ALIAS path, where `gatewayIds` IS attached and `hasForm` can
114
+ * genuinely be false. It is not a catalog-confirmed direct id either way,
115
+ * and callers that need that distinction should consult
116
+ * `curated-models.js`'s `directFormProvenance()`.
92
117
  * @param {Array<{id:string,name:string,contextLength:(number|null)}>} catalog
93
118
  * @param {string} vendor
94
119
  * @returns {Array<{id:string,name:string,contextLength:(number|null),pricePerMInput:(number|null),isPreselected:boolean}>}
@@ -109,7 +134,7 @@ function buildRows(catalog, vendor) {
109
134
  const isDirect = row.id.startsWith(directPrefix);
110
135
  const token = isDirect ? row.id.slice(directPrefix.length) : row.id.slice(orPrefix.length);
111
136
  const paired = pairAcrossGateways(vendor, token, catalogInfo);
112
- const chosenId = chooseRowId(vendor, isDirect, row, paired);
137
+ const chosenId = chooseRowId(vendor, isDirect, row, paired, catalogInfo);
113
138
  if (!chosenId || seenIds.has(chosenId)) { continue; }
114
139
  seenIds.add(chosenId);
115
140
 
@@ -124,7 +149,7 @@ function buildRows(catalog, vendor) {
124
149
  // pricing (`sourceRow`, which for a local/direct row IS the catalog row
125
150
  // itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
126
151
  // direct (non-local) row with no twin still renders `pricePerMInput:
127
- // null` (tests/provider-default-picker.test.js:59, pinned).
152
+ // null` (tests/provider-default-picker.test.js:60, pinned).
128
153
  const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
129
154
 
130
155
  rows.push({
@@ -140,22 +165,22 @@ function buildRows(catalog, vendor) {
140
165
 
141
166
  /**
142
167
  * Canonicalize `resolveTier`'s verbatim catalog-id output the same way row
143
- * ids are built (`chooseRowId`), so it can be matched against `rows`
144
- * exactly. `resolveTier` already prefers a real direct id when one matches
145
- * the tier pattern (`model-tiers.js`'s `pickForTier` tries the direct
146
- * namespace first), so this only has an effect when it fell back to an
147
- * OpenRouter id (no matching direct-namespace row at all). For
148
- * `DIVERGENT_VENDORS`, that OpenRouter id must stay OpenRouter-prefixed --
149
- * stripping it would fabricate a non-direct-callable dot-form id that no row
150
- * carries. Non-divergent vendors keep the pre-existing strip, safe because
151
- * their direct and OpenRouter ids are identical once the prefix is removed.
168
+ * ids are built (`chooseRowId`/`directFormIfSafe`), so it matches `rows`
169
+ * exactly. Only matters when `resolveTier` fell back to an OpenRouter id (no
170
+ * matching direct-namespace row for the tier). `directFormIfSafe` gates
171
+ * `DIVERGENT_VENDORS` itself and keeps that id OpenRouter-prefixed;
172
+ * non-divergent vendors go through the same `classifyModel`-guarded strip
173
+ * `chooseRowId` uses (issue 195) -- an unconditional strip here would
174
+ * produce a canonical id `rows` no longer carries whenever `directFormIfSafe`
175
+ * kept the row itself OpenRouter-prefixed, defeating the match below.
152
176
  * @param {string} vendor
153
177
  * @param {string|null} resolved verbatim catalog id from `resolveTier`, or null
178
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
154
179
  * @returns {string|null}
155
180
  */
156
- function canonicalizeResolved(vendor, resolved) {
181
+ function canonicalizeResolved(vendor, resolved, catalogInfo) {
157
182
  if (!resolved) { return null; }
158
- return DIVERGENT_VENDORS.has(vendor) ? resolved : toCanonicalDefault(resolved);
183
+ return directFormIfSafe(vendor, resolved, catalogInfo);
159
184
  }
160
185
 
161
186
  /**
@@ -173,7 +198,9 @@ function canonicalizeResolved(vendor, resolved) {
173
198
  function computePreselectedId(vendor, tier, catalog, rows) {
174
199
  const effectiveTier = tier || getCostTier();
175
200
  const resolved = resolveTier(vendor, effectiveTier, catalog);
176
- const canonical = canonicalizeResolved(vendor, resolved);
201
+ // F2 (B5): guard the same way applyProviderDefault does, so classifyModel
202
+ // never sees a non-array `models` regardless of what a caller passes.
203
+ const canonical = canonicalizeResolved(vendor, resolved, { models: Array.isArray(catalog) ? catalog : [] });
177
204
  if (canonical && rows.some(r => r.id === canonical)) { return canonical; }
178
205
 
179
206
  const priced = rows.filter(r => r.pricePerMInput !== null);
@@ -217,22 +244,35 @@ function buildProviderDefaultChoices(vendor, options = {}) {
217
244
  * `config.default` on first use. Read-modify-write, NO-CLOBBER -- preserves
218
245
  * an already-set `config.default` and every other existing alias/key.
219
246
  *
220
- * `chosenId` is a verbatim catalog id straight from `buildProviderDefaultChoices`
221
- * (via `chooseRowId`/`computePreselectedId`): a real direct id, or -- for a
222
- * `DIVERGENT_VENDORS` vendor with no direct twin -- an `openrouter/`-prefixed
223
- * id. **Never** run `toCanonicalDefault` on a divergent vendor's id: that
224
- * would strip the `openrouter/` prefix and fabricate a non-direct-callable
225
- * dot-form id no row actually carries (the exact bug just fixed in Task 4's
226
- * `chooseRowId`/`canonicalizeResolved`). Non-divergent vendors' direct and
227
- * OpenRouter ids are identical once the prefix is stripped, so
228
- * `toCanonicalDefault` is safe there.
247
+ * `chosenId` comes straight from `buildProviderDefaultChoices`: a real
248
+ * direct id; an `openrouter/`-prefixed id (`DIVERGENT_VENDORS`, or -- since
249
+ * issue 195 -- a non-divergent vendor whose bare form would classify
250
+ * `invalid`); or a bare id already SYNTHESISED via `directFormIfSafe`. By
251
+ * the time this function runs, `chosenId`'s prefix (or lack of one) IS the
252
+ * decision -- PERSISTENCE therefore uses `directFormIfProven`, not
253
+ * `directFormIfSafe`: it strips the `openrouter/` prefix only on POSITIVE
254
+ * evidence (`classifyModel` returns `valid` -- the bare id is an actual
255
+ * catalog row), never merely because the catalog couldn't disprove it.
256
+ *
257
+ * This matters because `directFormIfSafe`'s optimistic default is correct
258
+ * for LIST-BUILDING (an empty/absent catalog can't assert absence, so a
259
+ * bare guess is reasonable to offer) but wrong here: a catalog fetch that
260
+ * failed or came back empty (`catalog` omitted, or `[]`) must never be read
261
+ * as license to fabricate a direct id `chooseRowId` never actually offered
262
+ * -- that reintroduces the exact bug issue 195 fixed, silently, on every
263
+ * degraded fetch. `directFormIfProven` preserves `chosenId` verbatim
264
+ * whenever the catalog can't prove the bare form valid, so an empty/absent
265
+ * `catalog` option is inert (no canonicalization at all), not a fallback to
266
+ * the old unconditional strip.
229
267
  * @param {string} vendor e.g. 'anthropic'
230
- * @param {string} chosenId verbatim catalog id from the picker
231
- * @param {{seedDefaultIfAbsent?: boolean}} [options]
268
+ * @param {string} chosenId id from the picker (see above -- not always
269
+ * catalog-verbatim)
270
+ * @param {{seedDefaultIfAbsent?: boolean, catalog?: Array<{id:string}>}} [options]
232
271
  * @returns {{alias: string, setAsDefault: boolean}}
233
272
  */
234
- function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } = {}) {
235
- const storedId = DIVERGENT_VENDORS.has(vendor) ? chosenId : toCanonicalDefault(chosenId);
273
+ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true, catalog } = {}) {
274
+ const catalogInfo = { models: Array.isArray(catalog) ? catalog : [] };
275
+ const storedId = directFormIfProven(vendor, chosenId, catalogInfo);
236
276
 
237
277
  const config = loadConfig() || {};
238
278
  if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
@@ -246,4 +286,12 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
246
286
  return { alias: vendor, setAsDefault };
247
287
  }
248
288
 
249
- module.exports = { buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom };
289
+ // directFormIfSafe/directFormIfProven are re-exported (not just used
290
+ // internally) so a caller that wants the canonicalization primitives
291
+ // directly doesn't need to know they now live in model-canonicalization.js
292
+ // -- see tests/model-canonicalization.test.js for the identity pin proving
293
+ // this is a re-export, not a second, divergeable copy.
294
+ module.exports = {
295
+ buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom,
296
+ directFormIfSafe, directFormIfProven,
297
+ };
@@ -111,7 +111,7 @@ async function runProviderDefaultFlow(provider, options = {}) {
111
111
  chosenId = await promptForChoice(ask, print, choices);
112
112
  }
113
113
 
114
- const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
114
+ const { setAsDefault } = applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
115
115
  const summaryLine = `\`amicus start --model ${provider}\` → ${chosenId}` +
116
116
  (setAsDefault ? ', set as your default model' : '');
117
117
 
@@ -79,8 +79,8 @@ function resolveQuickPicks(catalog) {
79
79
  * Stripping the prefix there fabricates an id the direct API rejects, which
80
80
  * `amicus doctor` then reports as a stale alias. The row's own direct route is
81
81
  * used verbatim, falling back to the intact `openrouter/` form when the
82
- * catalog offered no direct pick. Mirrors the guard already used at
83
- * `provider-default-picker.js:82,143,220`.
82
+ * catalog offered no direct pick. Mirrors the DIVERGENT_VENDORS-first guard
83
+ * `model-canonicalization.js :: directFormIfSafe` uses internally.
84
84
  * @param {{vendorPath?:string, routes?:Object<string,string>}} pick
85
85
  * @returns {string|undefined}
86
86
  */
@@ -130,6 +130,43 @@ const REMEDIATION_HINTS = Object.freeze({
130
130
  */
131
131
  sweepSessionMetadataTmp:
132
132
  'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
133
+
134
+ /**
135
+ * Stale sessions-index.json entries (R16): session-index.js :: recordSession
136
+ * never removes a row, so a project that is deleted, renamed or moved
137
+ * leaves its taskId -> path entries behind forever, and every
138
+ * recordSession call pays for rewriting the WHOLE index — the per-start
139
+ * cost grows with total sessions ever, not live ones. `doctor --fix`
140
+ * removes only entries whose project path no longer exists on disk
141
+ * (liveness, never age — R16-2): a five-year-old entry for a project that
142
+ * still exists is left alone.
143
+ */
144
+ pruneSessionIndex:
145
+ 'amicus doctor --fix (removes sessions-index.json entries whose project no longer exists on disk — liveness-based, never by age)',
146
+
147
+ /**
148
+ * Fabricated bare alias (B3, council review of PR 198 / issue 195): v4.8.0
149
+ * could persist a `<vendor>/<model>` id no catalog row carries. `doctor
150
+ * --fix` rewrites ONLY the narrow, mechanically-unambiguous class -- an
151
+ * alias that classifies `invalid` on the `direct` gateway AND has an
152
+ * unambiguous OpenRouter twin (`pairAcrossGateways`, alias-audit.js's
153
+ * `findFabricatedAliasRepairs`) -- to that twin. Every other stale/drifted
154
+ * alias (typo, retired model, user-invented id) is left for `amicus
155
+ * models --check` instead, never guessed at.
156
+ */
157
+ repairFabricatedAlias:
158
+ 'amicus doctor --fix (rewrites a fabricated bare alias to its catalog-confirmed OpenRouter id — safe only for the narrow class doctor can prove; use `amicus models --check` for anything else)',
159
+
160
+ /**
161
+ * A3 (council review of PR 198): same repairable class as
162
+ * `repairFabricatedAlias` above, but the cached catalog `evaluateAliasesCheck`
163
+ * would repair FROM is itself stale (older than doctor's own `catalog`
164
+ * check's freshness window) -- a stale catalog can be missing rows that
165
+ * would make a "fabricated" id look repairable when it is merely unfetched,
166
+ * so the repair declines rather than write on unverified evidence.
167
+ */
168
+ repairFabricatedAliasStaleCatalog:
169
+ 'amicus models --refresh, then amicus doctor --fix (the alias looks fabricated but the cached catalog is stale — refresh it first so the repair rests on current data, not a possibly-incomplete snapshot)',
133
170
  });
134
171
 
135
172
  module.exports = REMEDIATION_HINTS;