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
@@ -20,6 +20,26 @@ const PROVIDER_NAMES = {
20
20
  deepseek: 'DeepSeek'
21
21
  };
22
22
 
23
+ /**
24
+ * F5: HTML-escape a value for safe interpolation into BOTH an attribute
25
+ * value (double-quoted) and element text content. Catalog ids are
26
+ * data-controlled (this repo's own convention documents them as such --
27
+ * see renderSearchResults in electron/setup-ui.js, which uses createElement
28
+ * + textContent instead of a template string for this exact row source),
29
+ * so every id/name reaching a template string here must be escaped rather
30
+ * than trusted.
31
+ * @param {*} value
32
+ * @returns {string}
33
+ */
34
+ function escapeAttr(value) {
35
+ return String(value === null || value === undefined ? '' : value)
36
+ .replace(/&/g, '&')
37
+ .replace(/</g, '&lt;')
38
+ .replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;')
40
+ .replace(/'/g, '&#39;');
41
+ }
42
+
23
43
  /**
24
44
  * Check if a model has at least one route with a configured key.
25
45
  * When no keys are configured at all (empty configuredKeys), all models are available
@@ -63,15 +83,68 @@ function buildModelSearchHTML() {
63
83
  </div>`;
64
84
  }
65
85
 
86
+ /**
87
+ * issue 138: the family -> model second level for one card. Renders EVERY model
88
+ * in one <select> (scrollable and type-ahead searchable, so nothing is
89
+ * hidden), grouped "Suggested" / "All N models". Returns '' when no
90
+ * shortlist was supplied (or its `total` is 0).
91
+ *
92
+ * That '' is NOT dropped by the caller — buildModelStepHTML always splices
93
+ * it into the card template as its own line, so a card with no shortlist
94
+ * gains one whitespace-only line versus the pre-issue-138 HTML string. It is NOT
95
+ * byte-for-byte identical to that old output. It IS behaviorally identical:
96
+ * no <select> is emitted, and whitespace-only text nodes are inert once
97
+ * parsed as HTML, so the rendered card is unchanged.
98
+ *
99
+ * CONTROLLER RULING R1 (issue 138, 2026-08-24): every <option> carries
100
+ * data-or="<openrouterId>" (empty string when the row has no OpenRouter
101
+ * form), so a later task can read the user's chosen route without
102
+ * re-deriving a gateway prefix from the value id.
103
+ * @param {string} alias
104
+ * @param {{recommendedId:string, suggested:Array<object>, rest:Array<object>, total:number}} [shortlist]
105
+ * @returns {string} HTML fragment
106
+ */
107
+ function buildModelPickHTML(alias, shortlist) {
108
+ if (!shortlist || !shortlist.total) { return ''; }
109
+ const opt = (r) => {
110
+ const price = r.pricePerMInput === null ? '' : ` · $${r.pricePerMInput.toFixed(2)}/M`;
111
+ const sel = r.isRecommended ? ' selected' : '';
112
+ return `<option value="${escapeAttr(r.id)}" data-or="${escapeAttr(r.openrouterId || '')}"${sel}>${escapeAttr(r.id)}${price}</option>`;
113
+ };
114
+ // Escaping-discipline consistency pass (council review, PR 196): alias
115
+ // is one of the five hardcoded FAMILIES names, not catalog data, so this
116
+ // is not closing a live vulnerability -- it matches the escapeAttr() use
117
+ // a few lines up for r.id/r.openrouterId, which ARE catalog-derived, so
118
+ // every attribute interpolation in this function follows the same rule.
119
+ let html = `<select class="model-pick" data-alias="${escapeAttr(alias)}">`;
120
+ html += `<optgroup label="Suggested">${shortlist.suggested.map(opt).join('')}</optgroup>`;
121
+ if (shortlist.rest.length > 0) {
122
+ // council review, PR 196 (F2): this optgroup holds ONLY `rest` -- label it
123
+ // by rest.length, not shortlist.total, or the label overstates what's in
124
+ // it (e.g. "All 14 models" over 6 rows when 8 are already under
125
+ // "Suggested" above). Singular/plural handled explicitly so a 9-row
126
+ // vendor with one leftover row doesn't read "1 models".
127
+ const restCount = shortlist.rest.length;
128
+ const restLabel = restCount === 1 ? '1 more model' : `${restCount} more models`;
129
+ html += `<optgroup label="${restLabel}">${shortlist.rest.map(opt).join('')}</optgroup>`;
130
+ }
131
+ return html + '</select>';
132
+ }
133
+
66
134
  /**
67
135
  * Build the HTML fragment for Step 2 (Model Selection).
68
136
  * @param {Array<{alias:string, label:string, blurb:string, source:string, routes:Object<string,string>}>} choices
69
137
  * Resolved rows from resolveQuickPicks(). Each row has separate label + blurb fields.
70
138
  * @param {string} [selectedAlias] - Pre-selected alias; defaults to first available choice.
71
139
  * @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for.
140
+ * @param {Object<string,object>} [shortlists] - issue 138: per-alias vendor shortlist
141
+ * from buildModelShortlist(), used to render the model-level <select>.
142
+ * Defaults to {}; omitting it (or passing {}) is behaviorally identical to
143
+ * today's card (no <select> for that alias) but not byte-for-byte identical
144
+ * to the pre-issue-138 HTML string — see buildModelPickHTML's docstring.
72
145
  * @returns {string} HTML fragment
73
146
  */
74
- function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
147
+ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}, shortlists = {}) {
75
148
  // Determine availability for each model
76
149
  const availability = choices.map(c => {
77
150
  const providers = Object.keys(c.routes);
@@ -102,6 +175,20 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
102
175
 
103
176
  // Resolved id for the write-preview (prefer bestProvider route)
104
177
  const previewId = c.routes[bestProvider] || Object.values(c.routes)[0] || '';
178
+ // Escaping-discipline consistency pass (council review, PR 196; extended
179
+ // by a second pass, N-c): NOT closing a live vulnerability -- c.alias is
180
+ // one of the five hardcoded FAMILIES names and previewId is filtered
181
+ // through resolveQuickPicks' anchored idPattern regexes (or a hardcoded
182
+ // fallback), neither of which can carry a payload. This applies the
183
+ // same escapeAttr() used a few lines up (buildModelPickHTML, for
184
+ // genuinely catalog-derived r.id/r.openrouterId) to every c.alias /
185
+ // previewId interpolation in this card template -- attributes
186
+ // (data-alias, the radio value) and text content alike (.model-alias,
187
+ // .model-resolved, .write-preview-id, the write-preview <code>s) --
188
+ // so a reader of this template does not have to work out which
189
+ // interpolations are "safe" and which are escaped; the rule is uniform.
190
+ const escapedAlias = escapeAttr(c.alias);
191
+ const escapedPreviewId = escapeAttr(previewId);
105
192
 
106
193
  // Offline badge for fallback rows
107
194
  const badge = c.source === 'fallback'
@@ -114,23 +201,26 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
114
201
  const pills = providers.map(p => {
115
202
  const isActive = p === bestProvider;
116
203
  const cls = isActive ? 'route-pill active' : 'route-pill';
117
- return `<button class="${cls}" data-alias="${c.alias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
204
+ return `<button class="${cls}" data-alias="${escapedAlias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
118
205
  }).join('');
119
206
  const toggleDisplay = showToggle ? '' : ' style="display:none"';
120
207
  const staticDisplay = showToggle ? ' style="display:none"' : '';
121
- routeHtml = `<span class="route-toggle" data-alias="${c.alias}"${toggleDisplay}>${pills}</span>`;
122
- routeHtml += `<span class="route-static" data-alias="${c.alias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
208
+ routeHtml = `<span class="route-toggle" data-alias="${escapedAlias}"${toggleDisplay}>${pills}</span>`;
209
+ routeHtml += `<span class="route-static" data-alias="${escapedAlias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
123
210
  } else {
124
211
  routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
125
212
  }
126
213
 
214
+ const modelPickHtml = buildModelPickHTML(c.alias, shortlists[c.alias]);
215
+
127
216
  return `<label class="${cardClass}">
128
- <input type="radio" name="default-model" value="${c.alias}" ${checked}${disabled}>
129
- <span class="model-alias">${c.alias}</span>
217
+ <input type="radio" name="default-model" value="${escapedAlias}" ${checked}${disabled}>
218
+ <span class="model-alias">${escapedAlias}</span>
130
219
  <span class="model-label">${c.label} — ${c.blurb}</span>${badge}
131
- <span class="model-resolved">${previewId}</span>
220
+ <span class="model-resolved" data-alias="${escapedAlias}">${escapedPreviewId}</span>
132
221
  ${routeHtml}
133
- <span class="write-preview" data-alias="${c.alias}">will set <code>${c.alias}</code> → <code class="write-preview-id">${previewId}</code></span>
222
+ ${modelPickHtml}
223
+ <span class="write-preview" data-alias="${escapedAlias}">will set <code>${escapedAlias}</code> → <code class="write-preview-id">${escapedPreviewId}</code></span>
134
224
  </label>`;
135
225
  }).join('\n ');
136
226
 
@@ -145,4 +235,4 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
145
235
  </div>`;
146
236
  }
147
237
 
148
- module.exports = { buildModelSearchHTML, buildModelStepHTML, PROVIDER_NAMES };
238
+ module.exports = { buildModelSearchHTML, buildModelStepHTML, buildModelPickHTML, PROVIDER_NAMES, escapeAttr };
@@ -192,6 +192,28 @@ function __rawWizardCSS() {
192
192
  margin-left: auto; font-size: 11px; color: var(--text-faint); font-style: italic;
193
193
  }
194
194
 
195
+ /* Model-level picker (Step 2 card) — issue 138. Mirrors .alias-model-select's
196
+ token-driven approach (background/border/radius/color/font/outline/cursor),
197
+ but this control lives inside a wrapping .model-card row alongside the
198
+ radio and route pills, not the Step-3 alias table's own linear row —
199
+ so it takes flex-basis:100% (its own line, below the route pills) rather
200
+ than alias-model-select's flex:1, and a plain --border (not --accent,
201
+ which the alias editor reserves for its active-edit state) so it reads
202
+ as a subordinate refinement, not the card's primary affordance. The
203
+ option/optgroup rules are load-bearing: Windows does not inherit a
204
+ <select>'s background/color into its <option>/<optgroup> children. */
205
+ .model-pick {
206
+ flex-basis: 100%; margin-top: 4px; padding: 3px 6px;
207
+ background: var(--surface); border: 1px solid var(--border);
208
+ border-radius: var(--r-3); color: var(--text-muted); font-size: 11px;
209
+ font-family: var(--font-mono);
210
+ outline: none; cursor: pointer; max-width: 280px;
211
+ }
212
+ .model-pick:hover { border-color: var(--border-strong); }
213
+ .model-pick:focus { border-color: var(--accent); }
214
+ .model-pick option { background: var(--surface); color: var(--text); }
215
+ .model-pick optgroup { color: var(--text-muted); font-style: normal; }
216
+
195
217
  /* Routing example */
196
218
  .routing-example {
197
219
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-8);
@@ -20,15 +20,19 @@ const { listDirectProviders } = require('../src/utils/provider-registry');
20
20
  * @param {string} [options.client='code-local'] - Client type for branding
21
21
  * @param {Array} [options.quickPicks] - Resolved quick-pick rows from resolveQuickPicks(catalog).
22
22
  * Defaults to pinned fallbacks when not provided.
23
+ * @param {Object<string,object>} [options.shortlists] - issue 138: per-alias vendor
24
+ * shortlist from buildModelShortlist(), passed through to buildModelStepHTML
25
+ * for the model-level <select>. Defaults to {} (no drill-down rendered).
23
26
  */
24
27
  function buildSetupHTML(options = {}) {
25
28
  const {
26
29
  client = 'code-local',
27
30
  quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
31
+ shortlists = {},
28
32
  } = options;
29
33
  const brandName = getBrandName(client);
30
34
  const keysHtml = buildKeysStepHTML(PROVIDERS);
31
- const modelHtml = buildModelStepHTML(quickPicks);
35
+ const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
32
36
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
33
37
  const css = buildWizardCSS();
34
38
  const providersJson = JSON.stringify(PROVIDERS);
@@ -82,8 +86,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
82
86
  var directProviders = ${directProvidersJson};
83
87
  var routingChoices = {};
84
88
  var explicitRouteChoices = {};
89
+ // issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
90
+ // means "use the family flagship", i.e. today's behavior.
91
+ var modelChoiceIds = {};
92
+ var modelOpenrouterIds = {};
85
93
  var aliasEdits = {};
86
94
  var aliasDisplay = {};
95
+ // N1: the alias map as loaded from disk (init below), so buildReview can
96
+ // tell an actual change from a value-identical re-write. Stays {} until
97
+ // init resolves (a fresh/offline config has nothing saved yet, so every
98
+ // computed write IS new).
99
+ var savedAliases = {};
87
100
  window.availableModels = null;
88
101
  var keyValid = false, validatedKey = '';
89
102
  var $ = function(id) { return document.getElementById(id); };
@@ -141,6 +154,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
141
154
  window.customDefaultModel = cfg.default;
142
155
  }
143
156
  if (cfg && cfg.aliases) {
157
+ // N-a (council review, PR 196): a defensive copy, not the live cfg.aliases
158
+ // reference. Nothing in this file currently mutates cfg.aliases after
159
+ // this point (checked: every '.aliases[' site below is a read), so
160
+ // buildReview's N1 diff is not measured to be wrong today -- but the
161
+ // copy is one line and removes a fragile "never mutate this" invariant
162
+ // future edits would otherwise have to remember.
163
+ savedAliases = Object.assign({}, cfg.aliases); // N1: buildReview diffs against this
144
164
  modelChoicesData.forEach(function(mc) {
145
165
  var currentModel = cfg.aliases[mc.alias];
146
166
  if (currentModel) {
@@ -148,6 +168,62 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
148
168
  for (var i = 0; i < provs.length; i++) {
149
169
  if (mc.routes[provs[i]] === currentModel) { routingChoices[mc.alias] = provs[i]; break; }
150
170
  }
171
+ // F3: the shortlist <select> is server-rendered with its OWN
172
+ // recommendedId-derived "selected" option and never consults
173
+ // modelChoiceIds at init -- so a saved drill-down pick silently
174
+ // reverted to the family flagship on every reopen (Step 3's
175
+ // alias table showed the saved value while Step 2 showed a
176
+ // different one). Mirror the customDefaultModel restore just
177
+ // above: when the saved alias value names one of THIS card's
178
+ // shortlist rows, seed modelChoiceIds (the .model-pick change
179
+ // handler's own state) and push the same value into the DOM
180
+ // <select> so a no-op reopen-and-Finish round-trips cleanly.
181
+ var sel = document.querySelector('.model-pick[data-alias="' + mc.alias + '"]');
182
+ if (sel) {
183
+ var matchedByValue = false;
184
+ for (var j = 0; j < sel.options.length; j++) {
185
+ if (sel.options[j].value === currentModel) {
186
+ sel.value = currentModel;
187
+ modelChoiceIds[mc.alias] = currentModel;
188
+ modelOpenrouterIds[mc.alias] = sel.options[j].getAttribute('data-or') || null;
189
+ matchedByValue = true;
190
+ break;
191
+ }
192
+ }
193
+ // F3-OR (council review, issue 138): an explicit-OpenRouter
194
+ // drilled pick is saved as "openrouter/<vendor>/<model>"
195
+ // (pickRouteFor's explicit-OR branch below) -- that form
196
+ // matches no option's bare 'value', only its 'data-or'. Every
197
+ // option carries 'data-or' (CONTROLLER RULING R1, issue 138 --
198
+ // see buildModelPickHTML's docstring in setup-ui-model.js), so
199
+ // search for THAT match when the bare-value search above
200
+ // found nothing.
201
+ // Restoring modelChoiceIds alone here would round-trip the
202
+ // dropdown selection but make Finish write the BARE id back --
203
+ // silently converting the user's explicit "via OpenRouter"
204
+ // choice into direct-first policy routing on the very next
205
+ // save, which is worse than the visible flagship-revert this
206
+ // block exists to fix. So restore the WHOLE state pickRouteFor
207
+ // needs to reproduce the OR form: the option's bare value (for
208
+ // the <select> and modelChoiceIds), its data-or
209
+ // (modelOpenrouterIds), and the two flags pickRouteFor's
210
+ // explicit-OR branch checks -- routingChoices[alias] ===
211
+ // 'openrouter' and explicitRouteChoices[alias] -- which are
212
+ // exactly the flags the route-pill click handler sets, so the
213
+ // rendered pill agrees with the restored state too.
214
+ if (!matchedByValue) {
215
+ for (var k = 0; k < sel.options.length; k++) {
216
+ if (sel.options[k].getAttribute('data-or') === currentModel) {
217
+ sel.value = sel.options[k].value;
218
+ modelChoiceIds[mc.alias] = sel.options[k].value;
219
+ modelOpenrouterIds[mc.alias] = currentModel;
220
+ routingChoices[mc.alias] = 'openrouter';
221
+ explicitRouteChoices[mc.alias] = true;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ }
151
227
  }
152
228
  });
153
229
  Object.keys(cfg.aliases).forEach(function(k) {
@@ -224,6 +300,20 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
224
300
  // the row's first route. Returns the full model id or null.
225
301
  function pickRouteFor(mc) {
226
302
  if (!mc) { return null; }
303
+ // issue 138: an explicit per-model choice overrides the family flagship.
304
+ var picked = modelChoiceIds[mc.alias];
305
+ if (picked) {
306
+ // Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
307
+ // canonicalization below): picked/modelOpenrouterIds come straight
308
+ // from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
309
+ // (e.g. anthropic) that id can already BE its only-callable
310
+ // openrouter/<vendor>/... form -- stripping the prefix would
311
+ // fabricate a direct id nothing serves.
312
+ if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
313
+ return modelOpenrouterIds[mc.alias] || picked;
314
+ }
315
+ return picked;
316
+ }
227
317
  var provs = Object.keys(mc.routes);
228
318
  var prov = routingChoices[mc.alias];
229
319
  if (!prov || !mc.routes[prov]) {
@@ -363,21 +453,52 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
363
453
  kn.length > 0 ? kn.map(function(k) { return k + ' \\u2713'; }).join(', ') : 'None';
364
454
  var r = document.querySelector('input[name="default-model"]:checked');
365
455
  document.getElementById('review-model').textContent = window.customDefaultModel || (r ? r.value : 'Not selected');
366
- var writes = [];
367
- var r2 = document.querySelector('input[name="default-model"]:checked');
368
- if (!window.customDefaultModel && r2) {
369
- var mc2 = null;
370
- for (var i2 = 0; i2 < modelChoicesData.length; i2++) {
371
- if (modelChoicesData[i2].alias === r2.value) { mc2 = modelChoicesData[i2]; break; }
456
+ // F4: mirror the EXACT call Finish makes (collectAliasWrites), not just
457
+ // the checked radio, so Step 4 can never under-report what Finish is
458
+ // about to write. Before this fix, buildReview re-derived "writes" from
459
+ // the checked radio alone -- a drilled-down pick on a card that was NOT
460
+ // the checked default (or any drilled pick at all, under a custom
461
+ // default) was invisible here and fell through to the literal 'No
462
+ // alias changes', even though Finish wrote it. sidecar:save-config has
463
+ // no confirmation step, so this review IS the only gate.
464
+ var aliasWritesPreview = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
465
+ // N1: aliasWritesPreview is EVERY alias Finish would write, including
466
+ // ones whose value is already what's on disk -- after the F1 fix that
467
+ // is the NORMAL case on a plain reopen (init below seeds modelChoiceIds
468
+ // from cfg.aliases, so the drilled-down alias, the recommendedId, and
469
+ // the saved value are now the same string by construction). Finish
470
+ // still writes the full map (a value-identical write is harmless), but
471
+ // this review must only SHOW entries that actually differ from
472
+ // savedAliases -- otherwise a no-op reopen reports "N alias(es)
473
+ // modified" on the one screen that has no confirmation step after it.
474
+ // N-b (council review, PR 196): an alias that was NEVER in savedAliases
475
+ // reads as undefined there, while a delete-write (Step 3's delete
476
+ // button, for one of the five default aliases -- see aliasEdits[alias]
477
+ // = null in setup-ui-alias-script.js) is null. null !== undefined is
478
+ // true, so without this normalization a default alias that was never
479
+ // explicitly saved (a config written by an older/partial flow that
480
+ // skipped default-seeding -- addAlias() in src/sidecar/setup.js is one
481
+ // such path) would show "alias -> (deleted)" for deleting something
482
+ // that was never there. saveConfig() already drops falsy alias values
483
+ // (src/utils/config.js), so that write is a true no-op on disk; the
484
+ // review must agree. Reading savedAliases[alias] as null (not
485
+ // undefined) when the key is absent makes "delete of an absent alias"
486
+ // compare equal to "still absent" without changing any other case --
487
+ // every other savedAliases value here is a non-empty string.
488
+ var changedWrites = {};
489
+ Object.keys(aliasWritesPreview).forEach(function(alias) {
490
+ var oldVal = Object.prototype.hasOwnProperty.call(savedAliases, alias) ? savedAliases[alias] : null;
491
+ if (aliasWritesPreview[alias] !== oldVal) {
492
+ changedWrites[alias] = aliasWritesPreview[alias];
372
493
  }
373
- if (mc2) {
374
- var routeId2 = pickRouteFor(mc2);
375
- if (routeId2) { writes.push(mc2.alias + ' \\u2192 ' + routeId2); }
376
- }
377
- }
494
+ });
495
+ var writes = Object.keys(changedWrites).map(function(alias) {
496
+ var val = changedWrites[alias];
497
+ return alias + ' \\u2192 ' + (val === null ? '(deleted)' : val);
498
+ });
378
499
  document.getElementById('review-routing').textContent =
379
500
  writes.length > 0 ? writes.join(', ') : 'No alias changes';
380
- var editCount = Object.keys(aliasEdits).length;
501
+ var editCount = Object.keys(changedWrites).length;
381
502
  var reviewAliases = document.getElementById('review-aliases');
382
503
  if (reviewAliases) {
383
504
  reviewAliases.textContent = editCount > 0 ? editCount + ' alias(es) modified' : 'No changes';
@@ -401,27 +522,97 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
401
522
  updateWritePreviews();
402
523
  });
403
524
 
525
+ // issue 138: model-level drill-down <select> change handler.
526
+ document.addEventListener('change', function(e) {
527
+ var sel = e.target && e.target.closest ? e.target.closest('.model-pick') : null;
528
+ if (!sel) { return; }
529
+ var alias = sel.getAttribute('data-alias');
530
+ if (!alias) { return; }
531
+ modelChoiceIds[alias] = sel.value;
532
+ var opt = sel.options[sel.selectedIndex];
533
+ modelOpenrouterIds[alias] = (opt && opt.getAttribute('data-or')) || null;
534
+ updateWritePreviews();
535
+ });
536
+
537
+ // issue 138 (fix round 1, Finding 1; precedence description corrected in
538
+ // the F2/F6 fix wave; modelChoiceIds provenance corrected in the N2 fix
539
+ // wave): assemble aliasWrites for Finish -- the aliasEdits (Step 3)
540
+ // overlay first; THEN, only for a checked quick-pick default (not a
541
+ // custom/searched one -- isCustomDefault skips this stage entirely), the
542
+ // selected alias's resolved route; then every OTHER alias whose
543
+ // drill-down <select> fired change -- "every OTHER" means every alias
544
+ // but the selected one ONLY when that earlier stage ran, so under a
545
+ // custom default the selected alias (if it has a drilled pick) is
546
+ // handled by THIS stage instead, not skipped. Before the Task-3 dropdown
547
+ // existed there was nothing to lose by writing only the selected alias;
548
+ // now a drilled-down pick on a card that is NOT the checked default
549
+ // would silently vanish without this. Precedence is NOT uniform across
550
+ // the two groups, unlike an earlier version of this comment claimed:
551
+ // only the selected alias clobbers its aliasEdits entry (the ONE place
552
+ // that's permitted -- user-locked decision #2); every OTHER drilled-down
553
+ // alias defers to aliasEdits and is written only when Step 3 left it
554
+ // untouched (see the hasOwnProperty guard below and ruling R6a, which
555
+ // already described the real behaviour correctly).
556
+ //
557
+ // modelChoiceIds is populated from TWO places, not one, unlike an
558
+ // earlier version of this comment claimed: the change handler above
559
+ // (a live drill-down pick), AND the init restore block (F3) -- which
560
+ // seeds it from cfg.aliases for every card whose SAVED value already
561
+ // names one of its shortlist rows, reading the id back out of that
562
+ // card's own server-rendered <option> elements. After the F1 fix that is
563
+ // the NORMAL case, not an edge case: a card the user never touched in
564
+ // THIS session routinely lands in modelChoiceIds anyway, because its
565
+ // saved value already matches its recommendedId. That is still correct
566
+ // to iterate here -- collectAliasWrites' job is to compute what Finish
567
+ // SHOULD write, and a value-identical write is harmless -- but it does
568
+ // mean this function can no longer be read as "only ever fires for
569
+ // aliases the user actually changed this session". buildReview is the
570
+ // layer responsible for not SHOWING those value-identical entries as
571
+ // changes (see its own N1 comment).
572
+ function collectAliasWrites(selectedAlias, isCustomDefault) {
573
+ var aliasWrites = {};
574
+ Object.keys(aliasEdits).forEach(function(k) {
575
+ aliasWrites[k] = aliasEdits[k];
576
+ });
577
+ function writeAliasRoute(alias) {
578
+ var mc = null;
579
+ for (var i = 0; i < modelChoicesData.length; i++) {
580
+ if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
581
+ }
582
+ if (!mc) { return; }
583
+ var routeId = pickRouteFor(mc);
584
+ if (routeId) { aliasWrites[mc.alias] = routeId; }
585
+ }
586
+ if (!isCustomDefault && selectedAlias) {
587
+ // Selecting a quick pick = explicit touch: upgrade that ONE alias
588
+ // to the resolved id via the chosen route (user-locked decision #2).
589
+ // This is the ONE place clobbering aliasEdits is permitted.
590
+ writeAliasRoute(selectedAlias);
591
+ }
592
+ // issue 138 (fix round 2, ruling R6a): every OTHER drilled-down alias is
593
+ // written ONLY when Step 3 left it untouched. hasOwnProperty.call, not
594
+ // the in operator and not a truthiness/not-undefined check:
595
+ // aliasEdits[alias] === null is a MEANINGFUL value here (ipc-setup.js:
596
+ // string = set, null = delete), so a falsy/undefined check would
597
+ // silently resurrect an alias the user explicitly deleted in Step 3,
598
+ // and the in operator or a not-undefined check can be fooled by an
599
+ // inherited Object.prototype property name (this codebase has been
600
+ // bitten by that class of bug before -- see the proto:null guard in
601
+ // src/sidecar/setup.js resolveChoice).
602
+ Object.keys(modelChoiceIds).forEach(function(alias) {
603
+ if (!isCustomDefault && alias === selectedAlias) { return; } // handled above, but only in that branch
604
+ if (Object.prototype.hasOwnProperty.call(aliasEdits, alias)) { return; }
605
+ writeAliasRoute(alias);
606
+ });
607
+ return aliasWrites;
608
+ }
609
+
404
610
  finishBtn.addEventListener('click', async function() {
405
611
  finishBtn.disabled = true; finishBtn.textContent = 'Saving...';
406
612
  try {
407
613
  var r = document.querySelector('input[name="default-model"]:checked');
408
614
  var dm = window.customDefaultModel || (r ? r.value : null);
409
- var aliasWrites = {};
410
- Object.keys(aliasEdits).forEach(function(k) {
411
- aliasWrites[k] = aliasEdits[k];
412
- });
413
- if (!window.customDefaultModel && r) {
414
- // Selecting a quick pick = explicit touch: upgrade that ONE alias
415
- // to the resolved id via the chosen route (user-locked decision #2).
416
- var mc = null;
417
- for (var i = 0; i < modelChoicesData.length; i++) {
418
- if (modelChoicesData[i].alias === r.value) { mc = modelChoicesData[i]; break; }
419
- }
420
- if (mc) {
421
- var routeId = pickRouteFor(mc);
422
- if (routeId) { aliasWrites[mc.alias] = routeId; }
423
- }
424
- }
615
+ var aliasWrites = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
425
616
  await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites, (window.collectCouncilPicks && window.collectCouncilPicks()) || []);
426
617
  var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
427
618
  await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
@@ -551,6 +742,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
551
742
  var idEl = el.querySelector('.write-preview-id');
552
743
  if (idEl && routeId) { idEl.textContent = routeId; }
553
744
  });
745
+ // issue 138: keep the resolved-id line in step with the route/model choice.
746
+ document.querySelectorAll('.model-resolved').forEach(function(el) {
747
+ var alias = el.getAttribute('data-alias');
748
+ var mc = null;
749
+ for (var i = 0; i < modelChoicesData.length; i++) {
750
+ if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
751
+ }
752
+ if (!mc) { return; }
753
+ var id = pickRouteFor(mc);
754
+ if (id) { el.textContent = id; }
755
+ });
554
756
  }
555
757
 
556
758
  document.addEventListener('input', function(e) {
@@ -99,6 +99,12 @@
99
99
  </div>
100
100
 
101
101
  <script src="./md-lite.js"></script>
102
+ <!-- v4.8 PR0: live-seats.js must load BEFORE live-model.js, whose bridge
103
+ reads window.AmicusLiveSeats in the browser. -->
104
+ <!-- v4.8 PR5c: live-dead-seats.js must load BEFORE live-seats.js, which re-exports
105
+ its deadSeats on window.AmicusLiveSeats. -->
106
+ <script src="./live-dead-seats.js"></script>
107
+ <script src="./live-seats.js"></script>
102
108
  <script src="./live-model.js"></script>
103
109
  <script src="./workspace-render.js"></script>
104
110
  <script src="./workspace-matrix.js"></script>
@@ -112,6 +118,9 @@
112
118
  Load order matters: workspace-app.js boots last and owns `state`. -->
113
119
  <script src="./workspace-panels.js"></script>
114
120
  <script src="./workspace-verbs.js"></script>
121
+ <!-- ⚠️ v4.8 PR5a fix-wave 3 extraction: workspace-banners.js must load BEFORE
122
+ workspace-app.js, whose renderBanners delegates to it. -->
123
+ <script src="./workspace-banners.js"></script>
115
124
  <script src="./workspace-app.js"></script>
116
125
  </body>
117
126
  </html>