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,228 @@
1
+ 'use strict';
2
+ // v4.8 PR5c: deadSeats, split out of live-seats.js for the 300-line gate — the FOURTH
3
+ // extraction this renderer's load-order list has absorbed. Moved verbatim from
4
+ // live-seats.js (which was itself split out of live-model.js in PR0), then seat-keyed.
5
+ // Self-contained: it reads only its four arguments and owns its own isReviewing.
6
+ (function () {
7
+ /**
8
+ * D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned
9
+ * from the run's own `degrades[]` (dead-leg/dead-wave channels — the live
10
+ * announcement; shapes verified against src/council/run-retry-notes.js and
11
+ * the skipped-path notes at src/council/run-stages.js:155-174) and the
12
+ * verdict's derived `seatLoss` (verdict.js summarizeSeatLoss/deriveSeatLoss
13
+ * — the critic-loss backstop, kept for verdicts written before `degrades[]`
14
+ * existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned
15
+ * FIRST, so a real record's retry marker always beats the backstop's
16
+ * no-data guess for the same seat.
17
+ *
18
+ * `retried` reads `data.retryWaveId` / `data.firstFailure`: every
19
+ * still-dead-after-retry note (run-retry-notes.js's four builders) carries
20
+ * `retryWaveId`; the two run-stages.js skipped-path notes (fired when the
21
+ * once-only retry pass never even attempted the seat — an unmappable lens
22
+ * loss, an out-of-range index, or a zero-model unit) carry neither, so they
23
+ * correctly fall back to the plain phrasing.
24
+ *
25
+ * D6 filter (zero usable legs ONLY, "no ghost when a live row already
26
+ * exists"): a candidate already present in `liveSeats` is dropped. Two
27
+ * different things can put it there, and both are reasons to suppress:
28
+ * an SL-2 retry actually healed it (a real recovered review), OR
29
+ * (owner-ruled, v4.7 CA-4 dead-seat convergence) the seat never recovered
30
+ * at all but the row-per-launch machinery still gives its dead leg an
31
+ * honest primary ERROR row (every billed leg gets a row now, including
32
+ * failures — run-stages.js/run-assemble.js) — so "it has a cost row" no
33
+ * longer implies "it healed". Either way that live row IS the seat's
34
+ * record; rendering a second "did not review" ghost row beside it would
35
+ * be a duplicate, not new information, so suppressing it here is the
36
+ * ACCEPTED terminal-path behavior: exactly one row per seat, whatever its
37
+ * status. This is the one thing standing between a recovered (or
38
+ * honestly-erred) seat and a duplicate/ghost row — same failure family as
39
+ * the F37 debate-role collision and the RN-11 keyed-row lessons just above
40
+ * (seatsFromRunStats, seatCells): an identity that is not carefully
41
+ * matched silently duplicates or overwrites instead of failing loud.
42
+ *
43
+ * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
44
+ * regardless of what the LIVE row's role was — so a model that died as
45
+ * critic but whose chair-fallback walk happened to land on that same alias
46
+ * (and succeeded, producing a live `role: 'chair'` cost row) silently
47
+ * erased the dead-critic row it was never a replacement for (spec §5, the
48
+ * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
49
+ * with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
50
+ * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
51
+ * suppress at all; a `'critic'` candidate is cleared only by a live
52
+ * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
53
+ * row landing on the same model. Hidden dependency: the recovered-critic
54
+ * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
55
+ * critic branch (src/council/run-stages.js), which only fires when lenses
56
+ * are absent — safe today only because --critic and --lenses are mutually
57
+ * exclusive (src/cli-handlers-council-run.js's `critic && lenses` check); if
58
+ * that exclusion ever loosens, a healed critic on a lens run would carry
59
+ * role 'seat' and this suppression would render a ghost dead row for it.
60
+ *
61
+ * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
62
+ * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
63
+ * (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
64
+ * existed on either doc for these runs. Consumed after the critic backstop,
65
+ * candidates get `role: null` (deadBenchSeats carries no critic/bench
66
+ * distinction beyond what `criticRequested` already covers above) and flow
67
+ * through the same dedup and role-aware suppression as every other candidate.
68
+ *
69
+ * ⚠️ v4.8 PR5c corrected HOW that dedup works, and the old description here was
70
+ * wrong once seats were keyed. Candidates arrive in FOUR identity flavours, and the
71
+ * flavour decides both the dedup key and whether the candidate may be absorbed:
72
+ * 'keyed' a real seat id -> dedup on it; two dead twins are two rows.
73
+ * 'unid' a wave slot the producer could not name (`seats[i] === null`). A
74
+ * DISTINCT seat we cannot spell, so it takes a private token; keying
75
+ * it on the alias is what collapsed two dead twins into one row.
76
+ * 'legacy' a pre-PR5c record with no seat id anywhere -> dedup on the alias,
77
+ * which still collapses twins. Disclosed residual R2.
78
+ * 'derivative' seatLoss-sourced. verdict.js:86 builds deadBenchSeats FROM the same
79
+ * dead legs that emit degrades[], so it is always a duplicate and is
80
+ * the ONLY flavour that may be absorbed by an alias already covered.
81
+ * Absorbing a real degrade instead would erase an unidentified twin.
82
+ *
83
+ * @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
84
+ * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent) —
85
+ * `criticRequested`/`criticSeated` back the critic candidate above,
86
+ * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
87
+ * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
88
+ * (or any seat list keyed the same way — the live seat map)
89
+ * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
90
+ * when no critic was requested) — degrade records carry no role field, so
91
+ * this is the ONLY way a degrade-sourced candidate is identified as critic
92
+ * @returns {Array<{model: string, seat: ?string, statusText: string, role: ?string}>}
93
+ * `model` is the ALIAS (what the row displays, what labelOf keys on); `seat` is
94
+ * the seat id when the record named one, else null.
95
+ */
96
+ function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
97
+ var critic = runMeta && runMeta.critic ? runMeta.critic : null;
98
+ // ⚠️ Object.create(null) throughout this family (also workspace-render.js's
99
+ // `existing`/`seen` and workspace-app.js's `labelByModel`): a model literally
100
+ // named `toString` is truthy off a bare object, so it was dropped here and —
101
+ // worse — crashed workspace-render.js:212 reading `.children` off an inherited
102
+ // function, killing the seats repaint and every tick after it.
103
+ var seen = Object.create(null);
104
+ var covered = Object.create(null);
105
+ var seenUnid = Object.create(null);
106
+ var order = [];
107
+ // `src` is the candidate's identity flavour — see the four-flavour table in the
108
+ // docblock above, which is what decides the key and the absorb rule.
109
+ function add(key, alias, retried, role, src, slotOf) {
110
+ if (!alias) { return; }
111
+ // ⛔ An 'unid' candidate was originally given NO dedup at all, on the grounds that it is
112
+ // one wave slot and distinct by construction. That holds WITHIN a record's seats[]; it
113
+ // does NOT hold across records, and the same dead-wave record appearing twice rendered
114
+ // four rows for two seats. Deduped on (waveId, slot) instead — its own namespace, never
115
+ // `seen`, so an invented key can never collide with a real alias. (The seat literally
116
+ // named `toString` is why this family refuses keys that share the alias keyspace.)
117
+ if (src === 'unid') {
118
+ var uk = ((slotOf && slotOf.waveId) || '') + '#' + (slotOf ? slotOf.i : 0);
119
+ if (seenUnid[uk]) { return; }
120
+ seenUnid[uk] = true;
121
+ }
122
+ if (src !== 'unid') {
123
+ var k = src === 'keyed' ? key : alias;
124
+ if (seen[k]) { return; }
125
+ if (src === 'derivative' && covered[alias]) { return; }
126
+ seen[k] = true;
127
+ }
128
+ // Covered either way: this alias HAS been announced, so a later seatLoss-derived
129
+ // duplicate naming it must still be absorbed.
130
+ covered[alias] = true;
131
+ var row = {
132
+ model: alias,
133
+ seat: src === 'keyed' ? key : null,
134
+ role: role || null,
135
+ statusText: retried ? 'did not review — retried once' : 'did not review',
136
+ };
137
+ // Set only when true, so every other row's shape is byte-identical to before —
138
+ // several suites assert these rows with an exact toEqual.
139
+ if (src === 'unid') { row.unnamed = true; }
140
+ order.push(row);
141
+ }
142
+ (degrades || []).forEach(function (d) {
143
+ if (!d || d.kind !== 'degrade') { return; }
144
+ if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
145
+ var data = d.data || {};
146
+ var retried = !!(data.retryWaveId || data.firstFailure);
147
+ // Critic identification mirrors deriveSeatLoss (verdict.js): alias
148
+ // equality with run.critic — degrade records carry no role field.
149
+ if (d.channel === 'dead-leg') {
150
+ var lk = (data.firstFailure && data.firstFailure.seatId) || data.seatId || null;
151
+ add(lk, data.seat, retried, critic && data.seat === critic ? 'critic' : null,
152
+ lk ? 'keyed' : 'legacy');
153
+ } else {
154
+ // `seats[]` is index-parallel with `models[]` (PR5c Task 1). Its ABSENCE means a
155
+ // legacy record; a null ELEMENT means an unidentified seat. Different statements.
156
+ var hasSeats = Array.isArray(data.seats);
157
+ (data.models || []).forEach(function (m, i) {
158
+ var wk = hasSeats ? data.seats[i] : null;
159
+ add(wk, m, retried, critic && m === critic ? 'critic' : null,
160
+ wk ? 'keyed' : (hasSeats ? 'unid' : 'legacy'), { waveId: data.waveId, i: i });
161
+ });
162
+ }
163
+ });
164
+ if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
165
+ add(null, seatLoss.criticRequested, false, 'critic', 'derivative');
166
+ }
167
+ if (seatLoss) {
168
+ // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
169
+ // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
170
+ // absorbs repeats and degrade-sourced duplicates.
171
+ (seatLoss.deadBenchSeats || []).forEach(function (m) {
172
+ add(null, m, false, null, 'derivative');
173
+ });
174
+ }
175
+ // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
176
+ // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
177
+ // a dead-critic candidate is cleared only by a live CRITIC leg. A null
178
+ // role is NOT reviewing: counting it would suppress silently, the exact
179
+ // class the announcement invariant forbids.
180
+ // Role 'claude' is deliberately absent: it is emitted only by claudeRunStatsRow
181
+ // (src/council/run-assemble.js:129-132) for a seat that never launches a leg, and
182
+ // preflightClaudeReview (run-assemble.js:86-102) rejects 'claude' as chair/critic/
183
+ // bench — so no 'claude' leg can die. If that reservation ever loosens, this
184
+ // allowlist is the single place to extend.
185
+ function isReviewing(role) {
186
+ return role === 'seat' || role === 'critic' ||
187
+ (typeof role === 'string' && role.indexOf('lens:') === 0);
188
+ }
189
+ var reviewing = Object.create(null);
190
+ var byRole = Object.create(null);
191
+ // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
192
+ // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
193
+ // executable id, not the alias a degrade record names; `modelInput` carries the alias.
194
+ // Keying this map on `s.model` alone meant a dead-leg seat whose errored roster row was
195
+ // still in the active stage's `liveSeats` never matched its alias-keyed degrade candidate,
196
+ // so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
197
+ // errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
198
+ // are already alias-only, so `|| s.model` leaves that path unchanged.
199
+ (liveSeats || []).forEach(function (s) {
200
+ if (!isReviewing(s.role)) { return; }
201
+ var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
202
+ reviewing[alias] = true;
203
+ // Both spaces. The alias arm keeps legacy/alias-only candidates suppressible; the seat
204
+ // arm is what lets a dead twin survive beside a live one. Guarded: `s.seat` is null on a
205
+ // unique-alias bench on BOTH paths — the terminal one (run-stats-entry.js ::
206
+ // buildRunStatsEntry) and, since v4.8 R5, the live tick (live-normalize.js :: seatOf).
207
+ // Both spell the same emit-when-DIFFERENT predicate, `seat.id !== seat.alias`, so the two
208
+ // producers cannot disagree. A bare insert would write a STRING key for every such seat.
209
+ if (s.seat) { reviewing[s.seat] = true; }
210
+ byRole[alias + '|' + s.role] = true;
211
+ });
212
+ return order.filter(function (s) {
213
+ if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
214
+ // An UNNAMED dead seat is never suppressed by alias evidence. The producer emitted
215
+ // `null` rather than the alias precisely because "unidentified" and "the alias" are
216
+ // different statements; falling back to the alias here would re-assert the equivalence
217
+ // that emission removed. A live d#1 does not prove the dead seat was d#1 — and a degrade
218
+ // record means it stayed dead after its retry, so any live seat sharing the alias is a
219
+ // DIFFERENT seat. Suppressing would be a silent loss on no evidence.
220
+ if (s.unnamed) { return true; }
221
+ return !reviewing[s.seat || s.model];
222
+ });
223
+ }
224
+
225
+ var api = { deadSeats: deadSeats };
226
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
227
+ if (typeof window !== 'undefined') { window.AmicusDeadSeats = api; }
228
+ })();
@@ -50,244 +50,18 @@
50
50
  return !isTerminal(status);
51
51
  }
52
52
 
53
- function dash(v) {
54
- return (v === null || v === undefined || v === '') ? '—' : String(v);
55
- }
56
-
57
- /**
58
- * Display cells for one seat row, in the seats-table column order:
59
- * [name, role, status, stage, msgs, tokens in/out, cost, last activity, stalled]
60
- */
61
- function seatCells(seat, blindOn, labelOf) {
62
- // ⚠️ DE-ROT (F36): key the lookup on the council ALIAS, never on `seat.model`. labelMap's
63
- // values are aliases; a LIVE leg's `model` is the resolved executable id, so labelOf(model)
64
- // misses and Blind ON renders the real model. `modelInput` is the alias Task 0.5 stamps per
65
- // leg (terminal rows from runStats are already alias-only, so the fallback is exact there).
66
- var alias = seat.modelInput || seat.model;
67
- var label = labelOf ? labelOf(alias) : null;
68
- // ⚠️ v4.4.1 RN-9: this hand-rolled flip is LOAD-BEARING — do NOT "fix" it into
69
- // AmicusRender.display({model: seat.model, label: label}, blindOn). The other two copies
70
- // (workspace-panels.js's review/judge titles) were routed through display() because their
71
- // pair's `model` IS the identity they must print. Here it is not: the seat's printable
72
- // identity is the council ALIAS resolved on the line above (F36 — a live leg's `seat.model`
73
- // is the RESOLVED executable id, e.g. `google/gemini-2.5-pro`, which labelMap never keys on),
74
- // so display()'s blind-OFF arm would print the resolved id and undo F36. This module is also
75
- // node-tested with NO DOM and no window.AmicusRender to call. Deliberate third copy.
76
- var name = blindOn && label ? label : alias;
77
- var tokens = (seat.tokensIn === null || seat.tokensIn === undefined) &&
78
- (seat.tokensOut === null || seat.tokensOut === undefined)
79
- ? '—'
80
- : dash(seat.tokensIn) + '/' + dash(seat.tokensOut);
81
- return [
82
- dash(name),
83
- dash(seat.role),
84
- dash(seat.status),
85
- dash(seat.stage),
86
- dash(seat.messages),
87
- tokens,
88
- dash(seat.costDisplay),
89
- // ⚠️ DE-ROT (F35): pass-through of an ISO timestamp, NOT a relative string. renderSeats
90
- // formats it with relTime() before calling in — live-model.js is node-tested and loads
91
- // before workspace-render.js, so it cannot reach relTime itself.
92
- dash(seat.lastActivity),
93
- seat.stalled ? '⏳ stalled' : '',
94
- ];
95
- }
96
-
97
- // v4.7 D6/E1: three row-per-launch producer roles added alongside the
98
- // existing chair-attempt (run-chair.js), repair (run-stages.js/
99
- // run-stage2.js/run-chair.js) and superseded (run-stages.js + debate.js)
100
- // rows — launch-accounting extras, not seats, and unlike rebuttal/revote
101
- // (F37, kept rendering on purpose below) they have no seats-panel meaning
102
- // of their own. There is no pre-existing allowlist in this function (E1) —
103
- // this is a plain exclusion added on top of the untouched id/shape logic.
104
- // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
105
- // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
106
- // function via bracket lookup instead of `undefined` — silently mis-excluding (or
107
- // mis-including) that seat. A null-prototype object has no inherited keys to collide with.
108
- var SEATS_PANEL_EXCLUDED_ROLES = Object.create(null);
109
- SEATS_PANEL_EXCLUDED_ROLES['chair-attempt'] = true;
110
- SEATS_PANEL_EXCLUDED_ROLES.repair = true;
111
- SEATS_PANEL_EXCLUDED_ROLES.superseded = true;
112
-
113
- /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
114
- function seatsFromRunStats(costRows) {
115
- return (costRows || []).filter(function (r) {
116
- return !SEATS_PANEL_EXCLUDED_ROLES[r.role];
117
- }).map(function (r) {
118
- return {
119
- // ⚠️ DE-ROT (F37): composite id — a v4.1 `--debate` run emits extra runStats rows for
120
- // the SAME bench alias (role 'rebuttal'/'revote', src/council/debate.js:88-96). With no
121
- // id, renderSeats keys on model and the re-vote row silently overwrites the seat row.
122
- // (If the panel is meant to be bench-only, filter to seat/critic/lens:* instead and
123
- // leave rebuttal/revote spend to the cost table — but say which; do not leave it implied.)
124
- id: r.model + ':' + (r.role || 'seat'),
125
- model: r.model, role: r.role || null, status: r.status || null,
126
- stage: null, messages: null, tokensIn: null, tokensOut: null,
127
- costDisplay: r.costDisplay || null, lastActivity: null, latestPreview: null,
128
- stalled: false,
129
- };
130
- });
131
- }
132
-
133
- /**
134
- * D6 (v4.6.2 PR4 Task 2, "dead-seat rows"): announced-dead seats, unioned
135
- * from the run's own `degrades[]` (dead-leg/dead-wave channels — the live
136
- * announcement; shapes verified against src/council/run-retry-notes.js and
137
- * the skipped-path notes at src/council/run-stages.js:155-174) and the
138
- * verdict's derived `seatLoss` (verdict.js summarizeSeatLoss/deriveSeatLoss
139
- * — the critic-loss backstop, kept for verdicts written before `degrades[]`
140
- * existed, v4.5.2 precedent). De-duped by seat model: `degrades` is scanned
141
- * FIRST, so a real record's retry marker always beats the backstop's
142
- * no-data guess for the same seat.
143
- *
144
- * `retried` reads `data.retryWaveId` / `data.firstFailure`: every
145
- * still-dead-after-retry note (run-retry-notes.js's four builders) carries
146
- * `retryWaveId`; the two run-stages.js skipped-path notes (fired when the
147
- * once-only retry pass never even attempted the seat — an unmappable lens
148
- * loss, an out-of-range index, or a zero-model unit) carry neither, so they
149
- * correctly fall back to the plain phrasing.
150
- *
151
- * D6 filter (zero usable legs ONLY, "no ghost when a live row already
152
- * exists"): a candidate already present in `liveSeats` is dropped. Two
153
- * different things can put it there, and both are reasons to suppress:
154
- * an SL-2 retry actually healed it (a real recovered review), OR
155
- * (owner-ruled, v4.7 CA-4 dead-seat convergence) the seat never recovered
156
- * at all but the row-per-launch machinery still gives its dead leg an
157
- * honest primary ERROR row (every billed leg gets a row now, including
158
- * failures — run-stages.js/run-assemble.js) — so "it has a cost row" no
159
- * longer implies "it healed". Either way that live row IS the seat's
160
- * record; rendering a second "did not review" ghost row beside it would
161
- * be a duplicate, not new information, so suppressing it here is the
162
- * ACCEPTED terminal-path behavior: exactly one row per seat, whatever its
163
- * status. This is the one thing standing between a recovered (or
164
- * honestly-erred) seat and a duplicate/ghost row — same failure family as
165
- * the F37 debate-role collision and the RN-11 keyed-row lessons just above
166
- * (seatsFromRunStats, seatCells): an identity that is not carefully
167
- * matched silently duplicates or overwrites instead of failing loud.
168
- *
169
- * Role-aware D6 (v4.6.3 PR2, spec D3): a bare model match used to suppress
170
- * regardless of what the LIVE row's role was — so a model that died as
171
- * critic but whose chair-fallback walk happened to land on that same alias
172
- * (and succeeded, producing a live `role: 'chair'` cost row) silently
173
- * erased the dead-critic row it was never a replacement for (spec §5, the
174
- * PR 102 rider). Candidates now carry a `role` (`'critic'` via alias equality
175
- * with `runMeta.critic` — mirroring `deriveSeatLoss`, verdict.js:72 — or
176
- * `null`), and only REVIEWING-role live legs (`seat`/`critic`/`lens:*`)
177
- * suppress at all; a `'critic'` candidate is cleared only by a live
178
- * CRITIC-role leg for that alias, never by a chair/judge/rebuttal/revote
179
- * row landing on the same model. Hidden dependency: the recovered-critic
180
- * suppression below (`byRole[alias + '|critic']`) relies on `roleFor`'s
181
- * critic branch (src/council/run-stages.js), which only fires when lenses
182
- * are absent — safe today only because --critic and --lenses are mutually
183
- * exclusive (src/cli-handlers-council-run.js's `critic && lenses` check); if
184
- * that exclusion ever loosens, a healed critic on a lens run would carry
185
- * role 'seat' and this suppression would render a ghost dead row for it.
186
- *
187
- * Old-run resilience (v4.6.3 PR2, spec D4): pre-`degrades[]` runs (v4.5.2)
188
- * carry the BENCH half of a seat loss only in `seatLoss.deadBenchSeats`
189
- * (string[] of aliases, verdict.js deriveSeatLoss) — `degrades[]` never
190
- * existed on either doc for these runs. Consumed after the critic backstop,
191
- * candidates get `role: null` (deadBenchSeats carries no critic/bench
192
- * distinction beyond what `criticRequested` already covers above) and flow
193
- * through the same `seen`-keyed dedup and role-aware suppression as every
194
- * other candidate — deriveSeatLoss does not dedup its own array, so `seen`
195
- * is what keeps a repeated alias (or one also named by a real degrade
196
- * record) from rendering twice.
197
- *
198
- * @param {Array<object>} degrades run.json's `degrades[]` (may be absent)
199
- * @param {?object} seatLoss verdict.json's `seatLoss` (may be absent) —
200
- * `criticRequested`/`criticSeated` back the critic candidate above,
201
- * `deadBenchSeats` (string[] of aliases) feeds the bench candidates below
202
- * @param {Array<{model: string}>} liveSeats seatsFromRunStats(...)'s output
203
- * (or any seat list keyed the same way — the live seat map)
204
- * @param {?{critic: ?string}} runMeta run.critic (alias, or null/absent
205
- * when no critic was requested) — degrade records carry no role field, so
206
- * this is the ONLY way a degrade-sourced candidate is identified as critic
207
- * @returns {Array<{model: string, statusText: string, role: ?string}>}
208
- */
209
- function deadSeats(degrades, seatLoss, liveSeats, runMeta) {
210
- var critic = runMeta && runMeta.critic ? runMeta.critic : null;
211
- // ⚠️ Object.create(null) throughout this family (also workspace-render.js's
212
- // `existing`/`seen` and workspace-app.js's `labelByModel`): a model literally
213
- // named `toString` is truthy off a bare object, so it was dropped here and —
214
- // worse — crashed workspace-render.js:212 reading `.children` off an inherited
215
- // function, killing the seats repaint and every tick after it.
216
- var seen = Object.create(null);
217
- var order = [];
218
- function add(model, retried, role) {
219
- if (!model || seen[model]) { return; }
220
- seen[model] = true;
221
- order.push({
222
- model: model,
223
- role: role || null,
224
- statusText: retried ? 'did not review — retried once' : 'did not review',
225
- });
226
- }
227
- (degrades || []).forEach(function (d) {
228
- if (!d || d.kind !== 'degrade') { return; }
229
- if (d.channel !== 'dead-leg' && d.channel !== 'dead-wave') { return; }
230
- var data = d.data || {};
231
- var retried = !!(data.retryWaveId || data.firstFailure);
232
- // Critic identification mirrors deriveSeatLoss (verdict.js): alias
233
- // equality with run.critic — degrade records carry no role field.
234
- if (d.channel === 'dead-leg') {
235
- add(data.seat, retried, critic && data.seat === critic ? 'critic' : null);
236
- } else {
237
- (data.models || []).forEach(function (m) {
238
- add(m, retried, critic && m === critic ? 'critic' : null);
239
- });
240
- }
241
- });
242
- if (seatLoss && seatLoss.criticRequested && !seatLoss.criticSeated) {
243
- add(seatLoss.criticRequested, false, 'critic');
244
- }
245
- if (seatLoss) {
246
- // Pre-degrades[] era (v4.5.2): the bench half of a seat loss lives
247
- // only here. Alias strings; deriveSeatLoss does not dedup — `seen`
248
- // absorbs repeats and degrade-sourced duplicates.
249
- (seatLoss.deadBenchSeats || []).forEach(function (m) { add(m, false, null); });
250
- }
251
- // Role-aware D6 (v4.6.3 PR2): only REVIEWING-role live legs suppress —
252
- // a chair/judge/rebuttal/revote row must not hide a dead reviewer, and
253
- // a dead-critic candidate is cleared only by a live CRITIC leg. A null
254
- // role is NOT reviewing: counting it would suppress silently, the exact
255
- // class the announcement invariant forbids.
256
- // Role 'claude' is deliberately absent: it is emitted only by claudeRunStatsRow
257
- // (src/council/run-assemble.js:129-132) for a seat that never launches a leg, and
258
- // preflightClaudeReview (run-assemble.js:86-102) rejects 'claude' as chair/critic/
259
- // bench — so no 'claude' leg can die. If that reservation ever loosens, this
260
- // allowlist is the single place to extend.
261
- function isReviewing(role) {
262
- return role === 'seat' || role === 'critic' ||
263
- (typeof role === 'string' && role.indexOf('lens:') === 0);
264
- }
265
- var reviewing = Object.create(null);
266
- var byRole = Object.create(null);
267
- // ⚠️ Fable review (PR4b fix wave): same F34/F36 alias-selection seatCells already uses
268
- // (`seat.modelInput || seat.model`, above) — a LIVE payload seat's `model` is the RESOLVED
269
- // executable id, not the alias a degrade record names; `modelInput` carries the alias.
270
- // Keying this map on `s.model` alone meant a dead-leg seat whose errored roster row was
271
- // still in the active stage's `liveSeats` never matched its alias-keyed degrade candidate,
272
- // so D6 failed to suppress it — both rows rendered until the stage boundary dropped the
273
- // errored row. Terminal-path cost rows (seatsFromRunStats) carry no `modelInput` at all and
274
- // are already alias-only, so `|| s.model` leaves that path unchanged.
275
- (liveSeats || []).forEach(function (s) {
276
- if (!isReviewing(s.role)) { return; }
277
- var alias = s.modelInput || s.model; // F36: alias space, never resolved ids
278
- reviewing[alias] = true;
279
- byRole[alias + '|' + s.role] = true;
280
- });
281
- return order.filter(function (s) {
282
- if (s.role === 'critic') { return !byRole[s.model + '|critic']; }
283
- return !reviewing[s.model];
284
- });
285
- }
53
+ // Seats surface lives in ./live-seats (v4.8 PR0 size-gate split), which
54
+ // loads first; re-exported on window.AmicusLive below so consumers are
55
+ // unchanged. In jest, require resolves it; in the renderer, the script
56
+ // load order guarantees window.AmicusLiveSeats exists.
57
+ var seats = (typeof module !== 'undefined' && module.exports)
58
+ ? require('./live-seats')
59
+ : window.AmicusLiveSeats;
286
60
 
287
61
  // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
288
- var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
289
- deadSeats: deadSeats,
290
- defaultBlind: defaultBlind, isTerminal: isTerminal, dash: dash,
62
+ var api = { pollDelay: pollDelay, seatCells: seats.seatCells, seatsFromRunStats: seats.seatsFromRunStats,
63
+ deadSeats: seats.deadSeats,
64
+ defaultBlind: defaultBlind, isTerminal: isTerminal, dash: seats.dash,
291
65
  TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
292
66
  if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
293
67
  if (typeof window !== 'undefined') { window.AmicusLive = api; }
@@ -0,0 +1,126 @@
1
+ // electron/workspace-ui/live-seats.js
2
+ // Seats surface: dash, seatCells, SEATS_PANEL_EXCLUDED_ROLES, seatsFromRunStats. Moved
3
+ // verbatim from live-model.js@ac7e7e12:53-285 (v4.8 PR0 size-gate split, zero behavior).
4
+ // `deadSeats` moved too, then PR5c split it to live-dead-seats.js :: deadSeats; it is
5
+ // only re-exported here. Loads BEFORE live-model.js, which re-exports on window.AmicusLive.
6
+ // ES5 IIFE, dual export, strict-CSP <script> loading — same shape as
7
+ // every renderer module. Comments write "PR 102", never the hash-number
8
+ // form (electron-token-drift HEX_RE trips on it — this file is scanned).
9
+ (function () {
10
+ 'use strict';
11
+
12
+ function dash(v) {
13
+ return (v === null || v === undefined || v === '') ? '—' : String(v);
14
+ }
15
+
16
+ /**
17
+ * Display cells for one seat row, in the seats-table column order:
18
+ * [name, role, status, stage, msgs, tokens in/out, cost, last activity, stalled]
19
+ */
20
+ function seatCells(seat, blindOn, labelOf) {
21
+ // ⚠️ DE-ROT (F36): key the lookup on the council ALIAS, never on `seat.model`. labelMap's
22
+ // values are aliases; a LIVE leg's `model` is the resolved executable id, so labelOf(model)
23
+ // misses and Blind ON renders the real model. `modelInput` is the alias Task 0.5 stamps per
24
+ // leg (terminal rows from runStats are already alias-only, so the fallback is exact there).
25
+ var alias = seat.modelInput || seat.model;
26
+ var label = labelOf ? labelOf(alias) : null;
27
+ // ⚠️ v4.4.1 RN-9: this hand-rolled flip is LOAD-BEARING — do NOT "fix" it into
28
+ // AmicusRender.display({model: seat.model, label: label}, blindOn). The other two copies
29
+ // (workspace-panels.js's review/judge titles) were routed through display() because their
30
+ // pair's `model` IS the identity they must print. Here it is not: the seat's printable
31
+ // identity is the council ALIAS resolved on the line above (F36 — a live leg's `seat.model`
32
+ // is the RESOLVED executable id, e.g. `google/gemini-2.5-pro`, which labelMap never keys on),
33
+ // so display()'s blind-OFF arm would print the resolved id and undo F36. This module is also
34
+ // node-tested with NO DOM and no window.AmicusRender to call. Deliberate third copy.
35
+ var name = blindOn && label ? label : alias;
36
+ var tokens = (seat.tokensIn === null || seat.tokensIn === undefined) &&
37
+ (seat.tokensOut === null || seat.tokensOut === undefined)
38
+ ? '—'
39
+ : dash(seat.tokensIn) + '/' + dash(seat.tokensOut);
40
+ return [
41
+ dash(name),
42
+ dash(seat.role),
43
+ dash(seat.status),
44
+ dash(seat.stage),
45
+ dash(seat.messages),
46
+ tokens,
47
+ dash(seat.costDisplay),
48
+ // ⚠️ DE-ROT (F35): pass-through of an ISO timestamp, NOT a relative string. renderSeats
49
+ // formats it with relTime() before calling in — live-model.js is node-tested and loads
50
+ // before workspace-render.js, so it cannot reach relTime itself.
51
+ dash(seat.lastActivity),
52
+ seat.stalled ? '⏳ stalled' : '',
53
+ ];
54
+ }
55
+
56
+ // v4.7 D6/E1: three row-per-launch producer roles added alongside the
57
+ // existing chair-attempt (run-chair.js), repair (run-stages.js/
58
+ // run-stage2.js/run-chair.js) and superseded (run-stages.js + debate.js)
59
+ // rows — launch-accounting extras, not seats, and unlike rebuttal/revote
60
+ // (F37, kept rendering on purpose below) they have no seats-panel meaning
61
+ // of their own. There is no pre-existing allowlist in this function (E1) —
62
+ // this is a plain exclusion added on top of the untouched id/shape logic.
63
+ // Object.create(null): a plain `{...}` literal inherits Object.prototype, so a role
64
+ // literally named 'constructor'/'toString'/etc would resolve to an inherited (truthy)
65
+ // function via bracket lookup instead of `undefined` — silently mis-excluding (or
66
+ // mis-including) that seat. A null-prototype object has no inherited keys to collide with.
67
+ var SEATS_PANEL_EXCLUDED_ROLES = Object.create(null);
68
+ SEATS_PANEL_EXCLUDED_ROLES['chair-attempt'] = true;
69
+ SEATS_PANEL_EXCLUDED_ROLES.repair = true;
70
+ SEATS_PANEL_EXCLUDED_ROLES.superseded = true;
71
+
72
+ /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
73
+ function seatsFromRunStats(costRows) {
74
+ return (costRows || []).filter(function (r) {
75
+ return !SEATS_PANEL_EXCLUDED_ROLES[r.role];
76
+ }).map(function (r) {
77
+ return {
78
+ // ⚠️ DE-ROT (F37) + v4.8 PR5b: composite id over the SEAT, not the alias.
79
+ // ROLE half: a v4.1 `--debate` run emits extra runStats rows for the SAME bench alias
80
+ // (role 'rebuttal'/'revote', src/council/debate.js :: debateRunStatsRows, merged at
81
+ // run-finish.js:43-48). Without it, renderSeats keys on model and the re-vote row
82
+ // silently overwrites the seat row.
83
+ // SEAT half: PR1 made a bench that repeats an alias produce distinct seats, and two of
84
+ // them collided on the alias. Measured at HEAD across two ticks: renderSeats appended
85
+ // BOTH rows on tick one, then froze the first forever — `existing` is snapshotted
86
+ // before the loop, so both seats resolved to the LAST row and the first was never
87
+ // re-matched, while `seen[key]` kept it from being removed.
88
+ // `r.seat` is emit-when-set upstream (src/council/run-stats-entry.js :: buildRunStatsEntry
89
+ // stamps it only when seat.id differs from seat.alias, which seats.js:67 makes true
90
+ // exactly for a repeated alias), so `|| r.model` is load-bearing — it keeps a unique
91
+ // bench byte-identical.
92
+ // JSON.stringify, not concatenation: an alias may contain ':' and roles include
93
+ // 'lens:*', so a concatenated key is NOT injective — ('a','lens:x') and ('a:lens','x')
94
+ // both spell 'a:lens:x'. Never displayed; it is only ever a dataset.key, and
95
+ // workspace-render.js:186 uses a plain object lookup so quotes are already safe.
96
+ id: JSON.stringify([r.seat || r.model, r.role || 'seat']),
97
+ // Carried for workspace-seats.js's retry-badge join (PR5b Task 2). Null on a unique
98
+ // bench, matching the upstream payload exactly.
99
+ seat: r.seat || null,
100
+ model: r.model, role: r.role || null, status: r.status || null,
101
+ stage: null, messages: null, tokensIn: null, tokensOut: null,
102
+ costDisplay: r.costDisplay || null, lastActivity: null, latestPreview: null,
103
+ stalled: false,
104
+ };
105
+ });
106
+ }
107
+
108
+
109
+ // deadSeats lives in ./live-dead-seats (v4.8 PR5c size-gate split), which loads first;
110
+ // re-exported here so every consumer — including window.AmicusLive's own bridge in
111
+ // live-model.js — is unchanged. In jest require resolves it; in the renderer the script
112
+ // load order guarantees window.AmicusDeadSeats exists.
113
+ var dead = (typeof module !== 'undefined' && module.exports)
114
+ ? require('./live-dead-seats')
115
+ : window.AmicusDeadSeats;
116
+
117
+ var api = {
118
+ dash: dash,
119
+ seatCells: seatCells,
120
+ SEATS_PANEL_EXCLUDED_ROLES: SEATS_PANEL_EXCLUDED_ROLES,
121
+ seatsFromRunStats: seatsFromRunStats,
122
+ deadSeats: dead.deadSeats,
123
+ };
124
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
125
+ if (typeof window !== 'undefined') { window.AmicusLiveSeats = api; }
126
+ })();