amicus 4.6.1 → 4.6.3

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +152 -0
  3. package/README.md +7 -8
  4. package/docs/ROADMAP.md +38 -4
  5. package/docs/configuration.md +18 -12
  6. package/docs/council.md +7 -2
  7. package/docs/troubleshooting.md +49 -20
  8. package/docs/usage.md +30 -3
  9. package/electron/setup-ui-aliases.js +2 -2
  10. package/electron/workspace-ui/index.html +3 -0
  11. package/electron/workspace-ui/live-model.js +146 -2
  12. package/electron/workspace-ui/workspace-app.js +8 -3
  13. package/electron/workspace-ui/workspace-panels.js +9 -10
  14. package/electron/workspace-ui/workspace-render.js +9 -3
  15. package/electron/workspace-ui/workspace-seats.js +132 -0
  16. package/electron/workspace-ui/workspace-verbs.js +2 -1
  17. package/electron/workspace-ui/workspace.css +6 -0
  18. package/package.json +1 -1
  19. package/schemas/alias-audit.schema.json +6 -1
  20. package/schemas/council-run.schema.json +14 -0
  21. package/src/cli-handlers-council.js +9 -0
  22. package/src/cli-handlers-doctor.js +25 -7
  23. package/src/cli.js +4 -0
  24. package/src/council/presets-cli.js +6 -2
  25. package/src/council/run-chair.js +55 -6
  26. package/src/headless.js +119 -9
  27. package/src/mcp-council-awareness.js +1 -0
  28. package/src/opencode-client.js +21 -0
  29. package/src/session-manager.js +6 -2
  30. package/src/sidecar/fanout-leg.js +2 -2
  31. package/src/sidecar/fanout.js +1 -1
  32. package/src/sidecar/models-probe.js +119 -0
  33. package/src/sidecar/models.js +81 -6
  34. package/src/utils/alias-audit.js +71 -1
  35. package/src/utils/base-url-classify.js +74 -0
  36. package/src/utils/council-presets.js +6 -2
  37. package/src/utils/curated-models.js +71 -16
  38. package/src/utils/doctor-base-url-check.js +41 -0
  39. package/src/utils/gateway-route-audit.js +16 -3
  40. package/src/utils/model-fetcher.js +9 -6
  41. package/src/utils/model-tiers.js +28 -7
  42. package/src/utils/no-output-backstop.js +48 -0
  43. package/src/utils/remediation-hints.js +14 -0
  44. package/src/utils/result-schema.js +29 -2
  45. package/src/utils/session-metadata-tmp-sweep.js +136 -0
  46. package/src/workspace/live-normalize.js +1 -0
@@ -102,6 +102,9 @@
102
102
  <script src="./live-model.js"></script>
103
103
  <script src="./workspace-render.js"></script>
104
104
  <script src="./workspace-matrix.js"></script>
105
+ <!-- ⚠️ D8 extraction (Task 1, v4.6.2 PR4): workspace-seats.js must load BEFORE
106
+ workspace-panels.js, whose renderSeatsPanel delegates to it. -->
107
+ <script src="./workspace-seats.js"></script>
105
108
  <!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
106
109
  Load order matters: workspace-app.js boots last and owns `state`. -->
107
110
  <script src="./workspace-panels.js"></script>
@@ -37,9 +37,17 @@
37
37
  return (state.visible && state.focused) ? 1500 : 5000;
38
38
  }
39
39
 
40
+ /** True when `status` is a terminal run status. The single consumption
41
+ * point for TERMINAL_STATUSES membership (v4.6.3 PR2 dedup) — the array
42
+ * itself stays exported and byte-identical to src/workspace/run-detail.js
43
+ * (drift-pinned). */
44
+ function isTerminal(status) {
45
+ return TERMINAL_STATUSES.indexOf(status) !== -1;
46
+ }
47
+
40
48
  /** Blind default per spec resolved Q2: ON live, OFF terminal. */
41
49
  function defaultBlind(status) {
42
- return TERMINAL_STATUSES.indexOf(status) === -1;
50
+ return !isTerminal(status);
43
51
  }
44
52
 
45
53
  function dash(v) {
@@ -104,9 +112,145 @@
104
112
  });
105
113
  }
106
114
 
115
+ /**
116
+ * D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned
117
+ * from the run's own `degrades[]` (dead-leg/dead-wave channels — the live
118
+ * announcement; shapes verified against src/council/run-retry-notes.js and
119
+ * the skipped-path notes at src/council/run-stages.js:155-174) and the
120
+ * verdict's derived `seatLoss` (verdict.js summarizeSeatLoss/deriveSeatLoss
121
+ * — the critic-loss backstop, kept for verdicts written before `degrades[]`
122
+ * existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned
123
+ * FIRST, so a real record's retry marker always beats the backstop's
124
+ * no-data guess for the same seat.
125
+ *
126
+ * `retried` reads `data.retryWaveId` / `data.firstFailure`: every
127
+ * still-dead-after-retry note (run-retry-notes.js's four builders) carries
128
+ * `retryWaveId`; the two run-stages.js skipped-path notes (fired when the
129
+ * once-only retry pass never even attempted the seat — an unmappable lens
130
+ * loss, an out-of-range index, or a zero-model unit) carry neither, so they
131
+ * correctly fall back to the plain phrasing.
132
+ *
133
+ * D6 filter (zero usable legs ONLY, "no ghost when a retry succeeded"): a
134
+ * candidate already present in `liveSeats` (it has a cost row — SL-2 healed
135
+ * it) is dropped. This is the one thing standing between a recovered seat
136
+ * and a duplicate/ghost row — same failure family as the F37 debate-role
137
+ * collision and the RN-11 keyed-row lessons just above (seatsFromRunStats,
138
+ * seatCells): an identity that is not carefully matched silently
139
+ * duplicates or overwrites instead of failing loud.
140
+ *
141
+ * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
142
+ * regardless of what the LIVE row's role was — so a model that died as
143
+ * critic but whose chair-fallback walk happened to land on that same alias
144
+ * (and succeeded, producing a live `role: 'chair'` cost row) silently
145
+ * erased the dead-critic row it was never a replacement for (spec §5, the
146
+ * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
147
+ * with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
148
+ * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
149
+ * suppress at all; a `'critic'` candidate is cleared only by a live
150
+ * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
151
+ * row landing on the same model. Hidden dependency: the recovered-critic
152
+ * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
153
+ * critic branch (src/council/run-stages.js), which only fires when lenses
154
+ * are absent — safe today only because --critic and --lenses are mutually
155
+ * exclusive (cli-handlers-council-run.js:196); if that exclusion ever
156
+ * loosens, a healed critic on a lens run would carry role 'seat' and this
157
+ * suppression would render a ghost dead row for it.
158
+ *
159
+ * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
160
+ * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
161
+ * (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
162
+ * existed on either doc for these runs. Consumed after the critic backstop,
163
+ * candidates get `role: null` (deadBenchSeats carries no critic/bench
164
+ * distinction beyond what `criticRequested` already covers above) and flow
165
+ * through the same `seen`-keyed dedup and role-aware suppression as every
166
+ * other candidate — deriveSeatLoss does not dedup its own array, so `seen`
167
+ * is what keeps a repeated alias (or one also named by a real degrade
168
+ * record) from rendering twice.
169
+ *
170
+ * @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
171
+ * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent) —
172
+ * `criticRequested`/`criticSeated` back the critic candidate above,
173
+ * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
174
+ * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
175
+ * (or any seat list keyed the same way — the live seat map)
176
+ * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
177
+ * when no critic was requested) — degrade records carry no role field, so
178
+ * this is the ONLY way a degrade-sourced candidate is identified as critic
179
+ * @returns {Array<{model: string, statusText: string, role: ?string}>}
180
+ */
181
+ function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
182
+ var critic = runMeta && runMeta.critic ? runMeta.critic : null;
183
+ var seen = {};
184
+ var order = [];
185
+ function add(model, retried, role) {
186
+ if (!model || seen[model]) { return; }
187
+ seen[model] = true;
188
+ order.push({
189
+ model: model,
190
+ role: role || null,
191
+ statusText: retried ? 'did not review — retried once' : 'did not review',
192
+ });
193
+ }
194
+ (degrades || []).forEach(function (d) {
195
+ if (!d || d.kind !== 'degrade') { return; }
196
+ if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
197
+ var data = d.data || {};
198
+ var retried = !!(data.retryWaveId || data.firstFailure);
199
+ // Critic identification mirrors deriveSeatLoss (verdict.js): alias
200
+ // equality with run.critic — degrade records carry no role field.
201
+ if (d.channel === 'dead-leg') {
202
+ add(data.seat, retried, critic && data.seat === critic ? 'critic' : null);
203
+ } else {
204
+ (data.models || []).forEach(function (m) {
205
+ add(m, retried, critic && m === critic ? 'critic' : null);
206
+ });
207
+ }
208
+ });
209
+ if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
210
+ add(seatLoss.criticRequested, false, 'critic');
211
+ }
212
+ if (seatLoss) {
213
+ // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
214
+ // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
215
+ // absorbs repeats and degrade-sourced duplicates.
216
+ (seatLoss.deadBenchSeats || []).forEach(function (m) { add(m, false, null); });
217
+ }
218
+ // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
219
+ // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
220
+ // a dead-critic candidate is cleared only by a live CRITIC leg. A null
221
+ // role is NOT reviewing: counting it would suppress silently, the exact
222
+ // class the announcement invariant forbids.
223
+ function isReviewing(role) {
224
+ return role === 'seat' || role === 'critic' ||
225
+ (typeof role === 'string' && role.indexOf('lens:') === 0);
226
+ }
227
+ var reviewing = {};
228
+ var byRole = {};
229
+ // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
230
+ // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
231
+ // executable id, not the alias a degrade record names; `modelInput` carries the alias.
232
+ // Keying this map on `s.model` alone meant a dead-leg seat whose errored roster row was
233
+ // still in the active stage's `liveSeats` never matched its alias-keyed degrade candidate,
234
+ // so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
235
+ // errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
236
+ // are already alias-only, so `|| s.model` leaves that path unchanged.
237
+ (liveSeats || []).forEach(function (s) {
238
+ if (!isReviewing(s.role)) { return; }
239
+ var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
240
+ reviewing[alias] = true;
241
+ byRole[alias + '|' + s.role] = true;
242
+ });
243
+ return order.filter(function (s) {
244
+ if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
245
+ return !reviewing[s.model];
246
+ });
247
+ }
248
+
107
249
  // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
108
250
  var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
109
- defaultBlind: defaultBlind, dash: dash, TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
251
+ deadSeats: deadSeats,
252
+ defaultBlind: defaultBlind, isTerminal: isTerminal, dash: dash,
253
+ TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
110
254
  if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
111
255
  if (typeof window !== 'undefined') { window.AmicusLive = api; }
112
256
  })();
@@ -6,8 +6,8 @@
6
6
  * ⚠️ DE-ROT (F05) / PRE-FLIGHT (P2): this file is the last of the three-way
7
7
  * split (workspace-app.js / workspace-panels.js / workspace-verbs.js). It
8
8
  * loads LAST (md-lite → live-model → workspace-render → workspace-matrix →
9
- * workspace-panels → workspace-verbs → workspace-app), so panels/verbs
10
- * already exist and may be captured once, here, at load time.
9
+ * workspace-seats → workspace-panels → workspace-verbs → workspace-app), so
10
+ * panels/verbs already exist and may be captured once, here, at load time.
11
11
  */
12
12
  (function () {
13
13
  'use strict';
@@ -67,6 +67,11 @@
67
67
  state.runId = runId;
68
68
  state.debate = null;
69
69
  return invoke('workspace:get-run', runId).then(function (detail) {
70
+ // F09 guard (v4.6.3 PR2): a reply for a run the user has since
71
+ // navigated away from must never overwrite the run now open. Guard on
72
+ // runId movement ONLY — a same-run re-open (the live loop's terminal
73
+ // refresh) must still apply its fresher reply.
74
+ if (state.runId !== runId) { return; }
70
75
  state.detail = detail;
71
76
  // ⚠️ DE-ROT (F38): debate.json is the re-vote index the matrix drill-in needs — fetched
72
77
  // once per run-open (never per render), fire-and-forget. An aborted or cost-ceiling
@@ -146,7 +151,7 @@
146
151
  P.renderVerdictPanel();
147
152
  R.renderCost($('cost-body'), d.derived.cost, state.blind, labelOf);
148
153
  P.wireLazyPanels();
149
- var isTerminal = window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1;
154
+ var isTerminal = window.AmicusLive.isTerminal(d.run.status);
150
155
  $('abort-btn').hidden = isTerminal;
151
156
  V.startLiveLoop();
152
157
  }
@@ -3,12 +3,12 @@
3
3
  * (v4.4 §5, ⚠️ DE-ROT F05 split of workspace-app.js).
4
4
  *
5
5
  * Loads BEFORE workspace-app.js (md-lite → live-model → workspace-render →
6
- * workspace-matrix → workspace-panels → workspace-verbs → workspace-app), so
7
- * every function here reads `window.AmicusApp` / `window.AmicusVerbs` at CALL
8
- * time (never captured at this file's own load time — neither namespace
9
- * exists yet when this IIFE runs). window.AmicusApp publishes its namespace
10
- * at the top of its own boot, before calling into this file, so by the time
11
- * any function below actually executes, both are present.
6
+ * workspace-matrix → workspace-seats → workspace-panels → workspace-verbs
7
+ * workspace-app), so every function here reads `window.AmicusApp` /
8
+ * `window.AmicusVerbs` at CALL time (never captured at this file's own load
9
+ * time — neither namespace exists yet when this IIFE runs). window.AmicusApp
10
+ * publishes its namespace at the top of its own boot, before calling into this
11
+ * file, so by the time any function below actually executes, both are present.
12
12
  */
13
13
  (function () {
14
14
  'use strict';
@@ -37,11 +37,10 @@
37
37
  return kind + '-' + sanitizeName(model) + '.md';
38
38
  }
39
39
 
40
+ // ⚠️ D8 extraction (Task 1): body moved verbatim to workspace-seats.js
41
+ // (window.AmicusSeats), which loads immediately before this file.
40
42
  function renderSeatsPanel() {
41
- var A = window.AmicusApp;
42
- var d = A.state.detail;
43
- var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
44
- window.AmicusRender.renderSeats(A.$('seats-body'), seats, A.state.blind, A.labelOf);
43
+ window.AmicusSeats.renderSeatsPanel();
45
44
  }
46
45
 
47
46
  function renderMatrixPanel() {
@@ -36,6 +36,12 @@
36
36
  return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
37
37
  }
38
38
 
39
+ /** td className for seat-table cell index i — one source for the three
40
+ * call sites (renderSeats create + update, renderDeadSeatRows). */
41
+ function seatCellClass(i) {
42
+ return i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '');
43
+ }
44
+
39
45
  /** Dual-name display flip (blind mode). pair = {model, label}. */
40
46
  function display(pair, blind) {
41
47
  if (!pair) { return '—'; }
@@ -197,7 +203,7 @@
197
203
  var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
198
204
  if (!row) {
199
205
  row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
200
- return el('td', { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
206
+ return el('td', { className: seatCellClass(i) }, [c]);
201
207
  }));
202
208
  tbody.appendChild(row);
203
209
  return;
@@ -205,7 +211,7 @@
205
211
  cells.forEach(function (c, i) {
206
212
  var td = row.children[i];
207
213
  if (td && td.textContent !== c) { td.textContent = c; }
208
- if (td) { td.className = i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : ''); }
214
+ if (td) { td.className = seatCellClass(i); }
209
215
  });
210
216
  });
211
217
  // RN-11 (v4.5): the keyed update added and removed rows but never MOVED
@@ -282,6 +288,6 @@
282
288
  el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
283
289
  renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
284
290
  renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
285
- renderProseSections: renderProseSections,
291
+ renderProseSections: renderProseSections, seatCellClass: seatCellClass,
286
292
  };
287
293
  })();
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Council Workspace — seats panel painter (v4.4 §5). D8 extraction (Task 1,
3
+ * v4.6.2 PR4): moved verbatim out of workspace-panels.js, which was pressed
4
+ * up against the 300-line size gate — this file is where Task 2 adds
5
+ * dead-seat rows. Loads immediately before workspace-panels.js (index.html),
6
+ * which keeps a thin delegate; reads `window.AmicusApp` at CALL time, same
7
+ * discipline as every sibling renderer file (workspace-app.js boots last and
8
+ * owns `state`).
9
+ *
10
+ * Task 2 ("dead-seat rows"): `state.detail.run` and `state.detail.verdict`
11
+ * are the raw run.json/verdict.json docs (src/workspace/run-detail.js —
12
+ * `getRunDetail` returns them wholesale, unfiltered), so `run.degrades` and
13
+ * `verdict.seatLoss` are already on `state.detail` today; no data-layer
14
+ * threading was needed. Only the derivation (window.AmicusLive.deadSeats,
15
+ * live-model.js) and this file's painting are new.
16
+ *
17
+ * NOTE (scope, matches the plan's file list): renderSeatsPanel() (below) is reached from
18
+ * renderDetail() — called both from openRun() (a fresh run open) and from the blind toggle
19
+ * (workspace-app.js:197-227), which must repaint dead rows too so the mask flip reaches
20
+ * them. A dead-leg/dead-wave degrade is checkpointed to run.json as soon as Stage 1's
21
+ * once-only retry pass resolves for that seat — which can be well before the rest of the run
22
+ * reaches a terminal status, so a seat CAN be "announced dead" in the data while the run is
23
+ * still live-polling.
24
+ *
25
+ * HISTORY: dead rows first shipped gated on terminal status (Task 2's fix wave, task
26
+ * review, controller ruling) — appended ONLY when window.AmicusLive.TERMINAL_STATUSES
27
+ * matched d.run.status, the same predicate startLiveLoop() uses at workspace-verbs.js:69 to
28
+ * decide whether a run is even worth polling. Reason at the time: renderDetail()'s
29
+ * unconditional V.startLiveLoop() call (workspace-app.js:151) schedules a
30
+ * setTimeout(tick, 0) on any still-running run; that tick's first resolution repainted
31
+ * #seats-body via applyLive's direct renderSeats() call (workspace-verbs.js:130), whose own
32
+ * leaver-removal (workspace-render.js:220-222) immediately deleted the `dead:`-keyed row
33
+ * renderSeatsPanel had just appended — a one-frame flash-then-vanish that read as a glitch,
34
+ * not a feature — so gating on terminal simply hid dead rows until no further tick could
35
+ * un-paint them. PR4b (Christian's mid-poll ruling on PR 102) replaced that gate with tick
36
+ * re-append: applyLive() now calls appendDeadRows() (below) immediately after every
37
+ * renderSeats() repaint (workspace-verbs.js:130-131), restoring the row the SAME tick that
38
+ * just wiped it instead of leaving it hidden. renderSeatsPanel() below no longer checks
39
+ * TERMINAL_STATUSES at all — dead rows paint unconditionally, on a live run or a done one.
40
+ */
41
+ (function () {
42
+ 'use strict';
43
+
44
+ function renderSeatsPanel() {
45
+ var A = window.AmicusApp;
46
+ var d = A.state.detail;
47
+ var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
48
+ var tbody = A.$('seats-body');
49
+ window.AmicusRender.renderSeats(tbody, seats, A.state.blind, A.labelOf);
50
+ var seatLoss = d.verdict && d.verdict.seatLoss;
51
+ var runMeta = { critic: (d.run && d.run.critic) || null };
52
+ // Source-selection (v4.6.3 PR2, spec D4): run-degrade.js swallows checkpoint failures, so
53
+ // verdict.json can carry degrade records run.json's own checkpoint lost — fall back to it
54
+ // ONLY when run.degrades is empty/absent. A fallback, never a union: both docs can carry
55
+ // records for the SAME run, and the persisted run.json copy is authoritative when present.
56
+ var deg = (d.run && d.run.degrades && d.run.degrades.length) ? d.run.degrades
57
+ : ((d.verdict && d.verdict.degrades) || []);
58
+ var dead = window.AmicusLive.deadSeats(deg, seatLoss, seats, runMeta);
59
+ renderDeadSeatRows(tbody, dead, A.state.blind, A.labelOf);
60
+ }
61
+
62
+ /**
63
+ * Paints the dead-seat rows appended after live rows. Deliberately NOT
64
+ * folded into workspace-render.js's renderSeats (293/300 — must not grow)
65
+ * and NOT run through its keyed diff: dead rows carry no per-tick-changing
66
+ * field, so a full rebuild every call is correct and cheap, and renderSeats
67
+ * just above already self-cleans any PRIOR dead row as an unrecognized
68
+ * `data-key` (its own seen-set only knows about the live `seats` it was
69
+ * just given), so nothing here needs to track dead rows across calls.
70
+ *
71
+ * Cells route through window.AmicusLive.seatCells(...) — the SAME function
72
+ * live rows use — so name masking (and every other column's blank/em-dash
73
+ * convention) matches exactly, not a reimplementation. Two overrides after
74
+ * the call: index 0 (name, blind-ON-and-unlabeled dead seats only — see the
75
+ * comment at that line) and index 6 (cost). seatCells would dash() a
76
+ * missing costDisplay to '—', indistinguishable from a seat that ran but
77
+ * whose cost is merely unmeasured (see cost-unknown-display.test.js) — a
78
+ * dead seat has no cost concept at all, so that cell renders empty instead
79
+ * (D6: "no cost cell").
80
+ */
81
+ function renderDeadSeatRows(tbody, dead, blindOn, labelOf) {
82
+ (dead || []).forEach(function (seat) {
83
+ var cells = window.AmicusLive.seatCells(
84
+ { model: seat.model, role: seat.role, status: seat.statusText, stalled: false }, blindOn, labelOf);
85
+ // Fix wave 2 (smoke-caught, GUI smoke on real degraded run 12c96b6b): dead seats never
86
+ // produce a review, so state.labelByModel (built from the run's names derivation — models
87
+ // that DID review) never carries them; seatCells' own `blindOn && label ? label : alias`
88
+ // fallback is LOAD-BEARING for LIVE rows (RN-9/F36, live-model.js) and stays untouched, but
89
+ // for a dead seat that fallback leaks the raw model name under blind — precisely the seat
90
+ // blind mode most needs to hide. Placeholder ONLY when blind is on AND no label resolved;
91
+ // a label that DOES resolve (possible in principle) still wins via seatCells' own cell.
92
+ if (blindOn && !(labelOf && labelOf(seat.model))) { cells[0] = '(masked)'; }
93
+ cells[6] = '';
94
+ var row = window.AmicusRender.el('tr',
95
+ { className: 'seat-dead', dataset: { key: 'dead:' + seat.model } },
96
+ cells.map(function (c, i) {
97
+ return window.AmicusRender.el('td',
98
+ { className: window.AmicusRender.seatCellClass(i) }, [c]);
99
+ }));
100
+ tbody.appendChild(row);
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Live-tick twin of renderSeatsPanel's dead block (PR4b, Christian's mid-poll
106
+ * ruling on PR 102): applyLive's renderSeats repaint wipes dead:-keyed rows
107
+ * (leaver-removal), so every tick re-appends from the tick's own payload.
108
+ * seatLoss comes from state.detail (absent mid-run — the critic's own
109
+ * dead-leg degrade covers it live; the terminal refresh unions the rest).
110
+ */
111
+ function appendDeadRows(live) {
112
+ var A = window.AmicusApp;
113
+ var d = A.state.detail;
114
+ var seatLoss = d && d.verdict ? d.verdict.seatLoss : null;
115
+ var runMeta = { critic: (d && d.run && d.run.critic) || null };
116
+ // Source-selection (v4.6.3 PR2, spec D4), live-path twin of renderSeatsPanel's fallback
117
+ // above: the tick's own live.degrades wins when non-empty; state.detail.verdict.degrades is
118
+ // usually absent mid-run (verdict.json doesn't exist until the run finishes) — fine, this
119
+ // branch only matters for the rare same-run reopen where a prior terminal fetch already
120
+ // populated state.detail.verdict.
121
+ var deg = (live.degrades && live.degrades.length) ? live.degrades
122
+ : ((d && d.verdict && d.verdict.degrades) || []);
123
+ var dead = window.AmicusLive.deadSeats(deg, seatLoss, live.seats || [], runMeta);
124
+ renderDeadSeatRows(A.$('seats-body'), dead, A.state.blind, A.labelOf);
125
+ }
126
+
127
+ window.AmicusSeats = {
128
+ renderSeatsPanel: renderSeatsPanel,
129
+ renderDeadSeatRows: renderDeadSeatRows,
130
+ appendDeadRows: appendDeadRows,
131
+ };
132
+ })();
@@ -66,7 +66,7 @@
66
66
  var A = window.AmicusApp;
67
67
  stopLiveLoop();
68
68
  var d = A.state.detail;
69
- if (!d || !d.run || window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1) { return; }
69
+ if (!d || !d.run || window.AmicusLive.isTerminal(d.run.status)) { return; }
70
70
  var epoch = A.state.liveEpoch; // F42: stopLiveLoop() above just bumped it; this chain owns it
71
71
  var tick = function () {
72
72
  // F42: pin the id PER TICK and SEND the pinned id — matching the reply against
@@ -128,6 +128,7 @@
128
128
  // `live.ok` is true, so this is simply "always paint," empty roster included.
129
129
  if (live.seats) {
130
130
  R.renderSeats(A.$('seats-body'), live.seats, A.state.blind, A.labelOf);
131
+ window.AmicusSeats.appendDeadRows(live);
131
132
  }
132
133
  // F42: state.detail can be swapped/absent under a tick — never deref .derived unguarded.
133
134
  var derived = A.state.detail && A.state.detail.derived ? A.state.detail.derived : null;
@@ -112,6 +112,12 @@ mark { background: var(--gold-soft); color: var(--gold-400); border-radius: var(
112
112
  .table td { border-bottom: var(--bd); padding: var(--space-3) var(--space-4); }
113
113
  .table td.num, .table th.num { text-align: right; font-family: var(--font-mono); }
114
114
  .stalled-flag { color: var(--warn); }
115
+ /* Task 2 (v4.6.2 PR4, "dead-seat rows"): an announced-dead seat's row —
116
+ muted like every other "nothing to see here" surface (.empty-note,
117
+ .chip.aborted, .stage.skipped, .rail-project all key off --text-3), never
118
+ the warn/danger colors those two-line notes use — a dead seat is a fact
119
+ already fully explained in the banner/verdict, not a NEW alarm here. */
120
+ tr.seat-dead td { color: var(--text-3); }
115
121
 
116
122
  /* ---- matrix tier rows (report-html light-ground pairs, as token vars) -- */
117
123
  .matrix-wrap { overflow-x: auto; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.6.1",
3
+ "version": "4.6.3",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -12,6 +12,11 @@
12
12
  "staleCount": { "type": "number" },
13
13
  "stale": { "type": "array", "items": { "type": "object" } },
14
14
  "gatewayFindingsCount": { "type": "number" },
15
- "gatewayFindings": { "type": "array", "items": { "type": "object" } }
15
+ "gatewayFindings": { "type": "array", "items": { "type": "object" } },
16
+ "driftedCount": { "type": "number" },
17
+ "drifted": { "type": "array", "items": { "type": "object" } },
18
+ "probeCount": { "type": "number" },
19
+ "probe": { "type": "array", "items": { "type": "object" } },
20
+ "probeSkipped": { "type": ["string", "null"] }
16
21
  }
17
22
  }
@@ -102,6 +102,20 @@
102
102
  }
103
103
  }
104
104
  },
105
+ "chairAttempts": {
106
+ "description": "v4.6.2 PR5 (LC-5): one entry per resolved chair fallback-walk attempt (ch1/ch2/ch3 — the ch4 VERDICT-line repair is not an attempt, see classifyChairAttempt), checkpointed incrementally so a mid-walk kill preserves the attempts already resolved. Additive; absent on chairless (cost-skipped) runs and on every pre-PR5 run.",
107
+ "type": "array",
108
+ "items": {
109
+ "type": "object",
110
+ "required": ["waveId", "model", "outcome"],
111
+ "properties": {
112
+ "waveId": { "type": "string" },
113
+ "model": { "type": "string" },
114
+ "outcome": { "enum": ["completed", "error", "timeout", "no-output"] },
115
+ "reason": { "type": ["string", "null"] }
116
+ }
117
+ }
118
+ },
105
119
  "debate": {
106
120
  "type": "object",
107
121
  "properties": {
@@ -162,6 +162,15 @@ function runVerdict(args, useJson) {
162
162
  hint: 'pass a valid decisions.json array or omit --decisions' });
163
163
  }
164
164
  }
165
+ // R1 (v4.6.3): parseArgs records a valueless trailing -o/--out as boolean
166
+ // true (and --out= as ''). The boolean crashes writeVerdictAtomic mid-write
167
+ // (renameSync TypeError on a non-string path) leaving an orphaned
168
+ // true.tmp-<pid>; the empty string silently falls through to the default
169
+ // path. Name the flag and refuse both — the unknown-flag precedent.
170
+ if (args.out !== undefined && (typeof args.out !== 'string' || args.out === '')) {
171
+ return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: '-o/--out requires a value',
172
+ hint: 'amicus council verdict <tally.json> [--decisions <decisions.json>] [-o|--out <out.json>]' });
173
+ }
165
174
  const outPath = args.out || './verdict.json';
166
175
  let verdict;
167
176
  try {
@@ -12,6 +12,8 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
12
12
  // local-providers check body (v4.2 §4.7 C8) — split out to keep this file
13
13
  // under the gate (mirrors the engineCheck/mcpChecks split above).
14
14
  const localProvidersCheck = require('./utils/doctor-local-providers-check');
15
+ // v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
16
+ const baseUrlCheck = require('./utils/doctor-base-url-check');
15
17
 
16
18
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
17
19
 
@@ -38,6 +40,7 @@ function realDeps() {
38
40
  readCache: () => require('./utils/model-catalog').readCache(),
39
41
  collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
40
42
  findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
43
+ findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
41
44
  hasOpencodeBinary: () => {
42
45
  // Single source of truth shared with the runtime server-start guard.
43
46
  const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
@@ -77,10 +80,14 @@ function realDeps() {
77
80
  now: () => Date.now(),
78
81
  listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
79
82
  unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
83
+ listSessionMetadataTmpFiles: () => metaSweep.listSessionMetadataTmpFiles(), // D8
84
+ unlinkSessionMetadataTmp: (n) => metaSweep.unlinkSessionMetadataTmp(n),
80
85
  };
81
86
  }
82
87
  // B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
83
88
  const tmpSweep = require('./utils/session-index-tmp-sweep');
89
+ // D8: per-session metadata.json sibling sweep — utils/session-metadata-tmp-sweep.js.
90
+ const metaSweep = require('./utils/session-metadata-tmp-sweep');
84
91
 
85
92
  /** Run one guarded check; a thrown fn becomes an error line. */
86
93
  function guard(id, name, fn) {
@@ -105,10 +112,10 @@ async function runDoctorChecks(depsOverride = {}) {
105
112
  const checks = [];
106
113
 
107
114
  checks.push(guard('node', 'Node.js', () => {
108
- const major = parseInt(String(d.nodeVersion).replace(/^v/, '').split('.')[0], 10);
109
- return major >= 18
115
+ const [maj, min] = String(d.nodeVersion).replace(/^v/, '').split('.').map(n => parseInt(n, 10));
116
+ return (maj > 22 || (maj === 22 && min >= 12))
110
117
  ? { id: 'node', name: 'Node.js', status: 'ok', message: d.nodeVersion, hint: null }
111
- : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=18)`, hint: 'Install Node 18 or newer from https://nodejs.org' };
118
+ : { id: 'node', name: 'Node.js', status: 'error', message: `${d.nodeVersion} (need >=22.12)`, hint: 'Install Node 22.12 or newer from https://nodejs.org' };
112
119
  }));
113
120
 
114
121
  checks.push(guard('config-dir', 'Config directory', () => (
@@ -148,12 +155,21 @@ async function runDoctorChecks(depsOverride = {}) {
148
155
  checks.push(guard('aliases', 'Model aliases', () => {
149
156
  const cache = d.readCache();
150
157
  const catalog = (cache && cache.models) || [];
151
- const stale = d.findStaleAliases(d.collectAliasSources(), catalog);
152
- return stale.length === 0
153
- ? { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null }
154
- : { id: 'aliases', name: 'Model aliases', status: 'warn', message: `${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`, hint: 'amicus models --check' };
158
+ const sources = d.collectAliasSources();
159
+ const stale = d.findStaleAliases(sources, catalog);
160
+ const drifted = d.findDriftedStoredAliases(sources, catalog);
161
+ if (stale.length === 0 && drifted.length === 0) {
162
+ return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
163
+ }
164
+ const parts = [];
165
+ if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
166
+ if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
167
+ return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
155
168
  }));
156
169
 
170
+ checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
171
+ () => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
172
+
157
173
  checks.push(guard('opencode-bin', 'OpenCode binary', () => (
158
174
  d.hasOpencodeBinary()
159
175
  ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
@@ -189,6 +205,8 @@ async function runDoctorChecks(depsOverride = {}) {
189
205
 
190
206
  checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
191
207
 
208
+ checks.push(guard('session-metadata-tmp', 'Session metadata tmp files', () => metaSweep.evaluateSessionMetadataTmpSweep(d)));
209
+
192
210
  // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
193
211
  checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
194
212
  const values = d.readApiKeyValues() || {};
package/src/cli.js CHANGED
@@ -149,6 +149,7 @@ const BOOLEAN_FLAGS = [
149
149
  'md', // council report: emit Markdown (default)
150
150
  'fix', // doctor: self-heal fixable checks in place (#56)
151
151
  'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
152
+ 'live', // models --check: opt-in probe of stored aliases with real engine legs (v4.6.2 PR3, spec §6)
152
153
  'render', // council verdict: also refresh report.html next to the decided verdict
153
154
  'claude', // init: register for Claude Code only (Task 15)
154
155
  'desktop', // init: register for Claude Desktop only (Task 15)
@@ -501,6 +502,9 @@ Options for 'models':
501
502
  --strict With --check: also exit non-zero on curated
502
503
  per-gateway drift (stale/divergent direct or
503
504
  openrouter forms). Informational without it.
505
+ --live With --check: probe every stored alias with one real
506
+ engine leg (spends) — served / accepted-but-silent /
507
+ error. Requires --check.
504
508
  --json Machine-readable output
505
509
  `,
506
510
  list: `
@@ -45,18 +45,22 @@ function runSave(name, modelsArg, useJson) {
45
45
  hint: 'each member must be a known alias (see `amicus models`) or a provider/model id containing "/"' });
46
46
  }
47
47
  const overwritten = !!getCouncil(name);
48
+ const shadowsBuiltin = listBuiltinCouncilNames().includes(name);
48
49
  const cfg = loadConfig() || {};
49
50
  if (!cfg.councils) { cfg.councils = {}; }
50
51
  cfg.councils[name] = members;
51
52
  saveConfig(cfg);
52
- const doc = { ok: true, name, models: members, overwritten };
53
+ const doc = { ok: true, name, models: members, overwritten, shadowsBuiltin };
53
54
  process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderSave(doc));
54
55
  return 0;
55
56
  }
56
57
 
57
58
  function renderSave(doc) {
58
59
  const notice = doc.overwritten ? ' (overwritten)' : '';
59
- return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` +
60
+ const shadow = doc.shadowsBuiltin
61
+ ? ` note: '${doc.name}' now shadows the built-in bench of the same name — 'amicus council list' shows both\n`
62
+ : '';
63
+ return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n` + shadow +
60
64
  " for full run configuration — chair, options, templates — see 'amicus pack'\n";
61
65
  }
62
66