amicus 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +4 -3
  4. package/electron/ipc-workspace.js +283 -0
  5. package/electron/main.js +27 -0
  6. package/electron/preload-workspace.js +40 -0
  7. package/electron/workspace-shell.js +85 -0
  8. package/electron/workspace-ui/index.html +111 -0
  9. package/electron/workspace-ui/live-model.js +101 -0
  10. package/electron/workspace-ui/md-lite.js +119 -0
  11. package/electron/workspace-ui/workspace-app.js +240 -0
  12. package/electron/workspace-ui/workspace-matrix.js +212 -0
  13. package/electron/workspace-ui/workspace-panels.js +226 -0
  14. package/electron/workspace-ui/workspace-render.js +271 -0
  15. package/electron/workspace-ui/workspace-verbs.js +247 -0
  16. package/electron/workspace-ui/workspace.css +172 -0
  17. package/package.json +1 -1
  18. package/schemas/council-run-live.schema.json +25 -1
  19. package/schemas/council-run.schema.json +14 -0
  20. package/schemas/progress.schema.json +14 -1
  21. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  22. package/src/cli-handlers-council-run.js +25 -3
  23. package/src/cli-handlers-spend.js +32 -5
  24. package/src/cli-handlers-watch.js +37 -10
  25. package/src/council/briefings.js +35 -2
  26. package/src/council/run-budget.js +224 -0
  27. package/src/council/run-launch.js +44 -6
  28. package/src/council/run-stages.js +17 -3
  29. package/src/council/run.js +12 -11
  30. package/src/headless.js +347 -14
  31. package/src/mcp-council-awareness.js +53 -3
  32. package/src/observe/council-legs.js +183 -0
  33. package/src/observe/live-doc.js +21 -3
  34. package/src/observe/watch-render.js +19 -0
  35. package/src/opencode-client.js +15 -3
  36. package/src/sidecar/child-sessions.js +198 -0
  37. package/src/sidecar/conversation-mirror.js +111 -37
  38. package/src/sidecar/fanout-budget.js +71 -0
  39. package/src/sidecar/fanout-leg.js +23 -1
  40. package/src/sidecar/fanout.js +4 -11
  41. package/src/sidecar/tool-part.js +196 -0
  42. package/src/sidecar/workspace-window.js +62 -0
  43. package/src/spend-query.js +21 -6
  44. package/src/utils/env-num.js +42 -0
  45. package/src/utils/path-fence.js +82 -0
  46. package/src/utils/pricing.js +98 -9
  47. package/src/workspace/artifact-guard.js +187 -0
  48. package/src/workspace/blind-mode.js +32 -0
  49. package/src/workspace/fold-format.js +95 -0
  50. package/src/workspace/live-normalize.js +156 -0
  51. package/src/workspace/matrix-model.js +94 -0
  52. package/src/workspace/run-detail.js +223 -0
  53. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Council Workspace — adjudication matrix + verdict panel painters.
3
+ * Tier row colors use the report-html light-ground token pairs
4
+ * (--tier-* / --tier-*-ink). Cells carry symbols + title/aria text (never
5
+ * color alone). Dispute cells drill into the judge's prose with the finding
6
+ * id highlighted client-side — HONESTY NOTE rendered with it: the Stage-2
7
+ * contract captures {id, verdict} only, so rationale is LOCATED IN prose,
8
+ * not parsed from it (spec §5.2).
9
+ */
10
+ (function () {
11
+ 'use strict';
12
+
13
+ var MATRIX_ROW_CAP = 500; // §5.4 safety valve
14
+
15
+ function verdictTitle(cell) {
16
+ var v = cell.verdict || 'no vote';
17
+ return display(cell.judge) + ': ' + v + (cell.isRaiser ? ' (raiser)' : '');
18
+ }
19
+
20
+ function display(pair) {
21
+ var blind = window.AmicusApp ? window.AmicusApp.isBlind() : false;
22
+ return window.AmicusRender.display(pair, blind);
23
+ }
24
+
25
+ // ⚠️ Fix-wave item 2 (F29): matrix-model.js's buildMatrixModel already attaches
26
+ // row.debate ({action, previousTier}, action ∈ defended|amended|withdrawn|no-response) on
27
+ // every --debate run — renderMatrix used to never read it, so a withdrawn/amended/
28
+ // defended/no-response finding rendered identically to an ordinary live row (the exact
29
+ // defect F29 was filed against). Rendered as a badge alongside the existing thin/
30
+ // tierOverride badges (same element shape, same CSS rule) rather than new machinery.
31
+ var DEBATE_LABEL = { withdrawn: 'withdrawn', amended: 'amended', defended: 'defended', 'no-response': 'no response' };
32
+
33
+ function debateBadge(R, debate, tier) {
34
+ if (!debate || !debate.action) { return null; }
35
+ var label = DEBATE_LABEL[debate.action] || debate.action;
36
+ var moved = !!(debate.previousTier && tier && debate.previousTier !== tier);
37
+ var arrow = moved ? ' (' + debate.previousTier + ' → ' + tier + ')' : '';
38
+ var title;
39
+ if (debate.action === 'withdrawn') {
40
+ title = 'withdrawn by raiser — no longer live' + (arrow || (debate.previousTier ? ' (was ' + debate.previousTier + ')' : ''));
41
+ } else if (debate.action === 'no-response') {
42
+ title = 'no response — original stands' + arrow;
43
+ } else {
44
+ title = debate.action + ' after re-vote' + (arrow || ' — tier unchanged');
45
+ }
46
+ return R.el('span', { className: 'debate-badge debate-' + debate.action, title: title }, [label]);
47
+ }
48
+
49
+ function renderMatrix(container, matrix, onDrill) {
50
+ var R = window.AmicusRender;
51
+ container.textContent = '';
52
+ if (!matrix) {
53
+ container.appendChild(R.el('p', { className: 'empty-note' }, ['tally.json not written yet — the matrix appears after the tally stage.']));
54
+ return;
55
+ }
56
+ if (!matrix.judged) {
57
+ container.appendChild(R.el('p', { className: 'truncate-note' }, ['Fewer than 2 judges completed — tally is peers-reduced.']));
58
+ }
59
+ var shown = matrix.rows.slice(0, MATRIX_ROW_CAP);
60
+ var head = R.el('tr', {}, [
61
+ R.el('th', {}, ['Finding']), R.el('th', {}, ['Sev']), R.el('th', {}, ['Raiser']),
62
+ ].concat(matrix.judges.map(function (j) {
63
+ return R.el('th', { className: 'num' }, [display(j)]);
64
+ })).concat([R.el('th', {}, ['Tier']), R.el('th', { className: 'num' }, ['a/d/n'])]));
65
+
66
+ var body = shown.map(function (row) {
67
+ var cells = [
68
+ R.el('td', { className: 'mono' }, [row.id]),
69
+ R.el('td', {}, [row.severity || '—']),
70
+ R.el('td', {}, [display(row.raiser)]),
71
+ ];
72
+ row.cells.forEach(function (cell) {
73
+ var td = R.el('td', {
74
+ className: 'vote-cell ' + (cell.verdict || ''),
75
+ title: verdictTitle(cell),
76
+ 'aria-label': verdictTitle(cell),
77
+ }, [cell.sym + (cell.isRaiser ? '*' : '')]);
78
+ if (cell.verdict === 'dispute') {
79
+ td.addEventListener('click', function () { onDrill(cell.judge, row.id); });
80
+ }
81
+ cells.push(td);
82
+ });
83
+ var tierTd = R.el('td', {}, [row.tier || '—']);
84
+ if (row.thin) { tierTd.appendChild(R.el('span', { className: 'thin-badge', title: 'thin confidence (a+d ≤ 1)' }, ['thin'])); }
85
+ if (row.tierOverride) {
86
+ tierTd.appendChild(R.el('span', {
87
+ className: 'override-badge',
88
+ title: 'override: ' + row.tierOverride.from + ' → ' + row.tierOverride.to,
89
+ }, ['override']));
90
+ }
91
+ var dBadge = debateBadge(R, row.debate, row.tier);
92
+ if (dBadge) { tierTd.appendChild(dBadge); }
93
+ cells.push(tierTd);
94
+ cells.push(R.el('td', { className: 'num' }, [row.basis.a + '/' + row.basis.d + '/' + row.basis.n]));
95
+ return R.el('tr', { className: 'tier-' + (row.tier || 'none'), dataset: { findingId: row.id } }, cells);
96
+ });
97
+
98
+ var table = R.el('table', { className: 'table' }, [R.el('thead', {}, [head]), R.el('tbody', {}, body)]);
99
+ container.appendChild(R.el('div', { className: 'matrix-wrap' }, [table]));
100
+ if (matrix.rows.length > MATRIX_ROW_CAP) {
101
+ container.appendChild(R.el('p', { className: 'truncate-note' }, [
102
+ 'Showing ' + MATRIX_ROW_CAP + ' of ' + matrix.rows.length + ' findings.',
103
+ ]));
104
+ }
105
+ // ⚠️ DE-ROT (F38): on `--debate` runs the re-vote rationale IS structured — debate.json
106
+ // `revotes[] {judge, id, verdict, reason}` (run-debate.js:257-262) — so the parenthetical must
107
+ // not claim "no structured field". Wording below is corrected to cover both cases.
108
+ container.appendChild(R.el('p', { className: 'empty-note' }, [
109
+ 'Legend: ✓ agree · ✗ dispute · – neutral · * raiser · click a dispute cell for the judge’s prose (rationale lives in prose; on --debate runs a re-voted cell also carries a structured reason from debate.json).',
110
+ ]));
111
+ }
112
+
113
+ function renderVerdict(container, vp, opts) {
114
+ var R = window.AmicusRender;
115
+ container.textContent = '';
116
+ var head = R.el('div', { className: 'chips' }, []);
117
+ if (vp.overallVerdict) {
118
+ head.appendChild(R.el('span', { className: 'chip complete' }, ['VERDICT: ' + vp.overallVerdict]));
119
+ } else {
120
+ head.appendChild(R.el('span', { className: 'chip error' }, ['no chair verdict']));
121
+ if (vp.reason) { head.appendChild(R.el('span', { className: 'empty-note' }, [vp.reason])); }
122
+ }
123
+ container.appendChild(head);
124
+
125
+ if (vp.tierCounts) {
126
+ container.appendChild(R.el('p', { className: 'mono' }, [
127
+ 'Confirmed ' + (vp.tierCounts.Confirmed || 0) + ' · Disputed ' + (vp.tierCounts.Disputed || 0) +
128
+ ' · Contested ' + (vp.tierCounts.Contested || 0) + ' · Singleton ' + (vp.tierCounts.Singleton || 0),
129
+ ]));
130
+ }
131
+
132
+ var chairHost = R.el('div', { id: 'chair-prose', className: 'prose-host' }, []);
133
+ container.appendChild(chairHost);
134
+
135
+ if (vp.streetCred && vp.streetCred.length) {
136
+ var rows = vp.streetCred.map(function (s) {
137
+ var label = opts.labelOf(s.model);
138
+ var name = opts.isBlind() && label ? label : s.model;
139
+ var fmt = function (v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(2); };
140
+ return R.el('tr', {}, [
141
+ R.el('td', {}, [name]),
142
+ R.el('td', { className: 'num' }, [R.el('strong', {}, [fmt(s.peersOnly)])]),
143
+ R.el('td', { className: 'num' }, [fmt(s.withSelf)]),
144
+ ]);
145
+ });
146
+ container.appendChild(R.el('table', { className: 'table' }, [
147
+ R.el('thead', {}, [R.el('tr', {}, [
148
+ R.el('th', {}, ['Street-cred']), R.el('th', { className: 'num' }, ['peers-only']), R.el('th', { className: 'num' }, ['with-self']),
149
+ ])]),
150
+ R.el('tbody', {}, rows),
151
+ ]));
152
+ }
153
+
154
+ if (vp.decisions && vp.decisions.length) {
155
+ container.appendChild(R.el('table', { className: 'table' }, [
156
+ R.el('thead', {}, [R.el('tr', {}, [R.el('th', {}, ['Finding']), R.el('th', {}, ['Decision']), R.el('th', {}, ['Applied'])])]),
157
+ R.el('tbody', {}, vp.decisions.map(function (d) {
158
+ return R.el('tr', {}, [
159
+ R.el('td', { className: 'mono' }, [d.id]),
160
+ R.el('td', {}, [d.decision]),
161
+ R.el('td', {}, [d.applied ? 'yes' : 'no']),
162
+ ]);
163
+ })),
164
+ ]));
165
+ }
166
+
167
+ var actions = R.el('div', { className: 'dialog-actions' }, [
168
+ R.el('button', { id: 'fold-btn', className: 'btn primary' }, ['Fold to Claude Code']),
169
+ R.el('button', { id: 'open-report-btn', className: 'btn' }, ['Open report.html']),
170
+ ]);
171
+ container.appendChild(actions);
172
+ actions.querySelector('#fold-btn').addEventListener('click', opts.onFold);
173
+ actions.querySelector('#open-report-btn').addEventListener('click', opts.onOpenReport);
174
+ if (!opts.reportPresent) { actions.querySelector('#open-report-btn').disabled = true; }
175
+ return chairHost;
176
+ }
177
+
178
+ /** Wrap every text-node occurrence of needle in <mark> (DOM-safe highlight). */
179
+ function highlightText(container, needle) {
180
+ if (!needle) { return; }
181
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
182
+ var nodes = [];
183
+ while (walker.nextNode()) { nodes.push(walker.currentNode); }
184
+ nodes.forEach(function (node) {
185
+ var idx = node.nodeValue.indexOf(needle);
186
+ if (idx === -1) { return; }
187
+ var after = node.splitText(idx);
188
+ after.splitText(needle.length);
189
+ var mark = document.createElement('mark');
190
+ mark.textContent = needle;
191
+ after.parentNode.replaceChild(mark, after);
192
+ });
193
+ }
194
+
195
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): undoes highlightText —
196
+ // needed because drillIntoJudge's prose section is built once (loadPanel's promise cache)
197
+ // and never rebuilt, so re-drilling into a DIFFERENT finding on the same judge must clear
198
+ // the PREVIOUS finding's <mark> before applying the new one, rather than leaving it stuck
199
+ // (the stale mark was what made the old idempotency guard misfire on a different finding).
200
+ function clearHighlight(container) {
201
+ var marks = container.querySelectorAll('mark');
202
+ for (var i = 0; i < marks.length; i++) {
203
+ var mark = marks[i];
204
+ if (mark.parentNode) { mark.parentNode.replaceChild(document.createTextNode(mark.textContent), mark); }
205
+ }
206
+ }
207
+
208
+ window.AmicusMatrix = {
209
+ renderMatrix: renderMatrix, renderVerdict: renderVerdict,
210
+ highlightText: highlightText, clearHighlight: clearHighlight, MATRIX_ROW_CAP: MATRIX_ROW_CAP,
211
+ };
212
+ })();
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Council Workspace — lazy/prose panels + the matrix/verdict panel adapters
3
+ * (v4.4 §5, ⚠️ DE-ROT F05 split of workspace-app.js).
4
+ *
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.
12
+ */
13
+ (function () {
14
+ 'use strict';
15
+
16
+ // ⚠️ DE-ROT (F61): keep this local mirror of the shipped sanitizeName (src/council/run-launch.js:92-94)
17
+ // — pinned with an equality assert in tests/electron/workspace-ui-static.test.js. Do NOT rebuild
18
+ // the lists from Object.keys(state.detail.artifacts): filenames carry the SANITIZED id, which cannot be
19
+ // inverted back to the model id that keys state.labelByModel, so blind labels would break.
20
+ function sanitizeName(model) { return String(model).replace(/[^a-zA-Z0-9._-]/g, '-'); }
21
+
22
+ function renderSeatsPanel() {
23
+ var A = window.AmicusApp;
24
+ var d = A.state.detail;
25
+ var seats = window.AmicusLive.seatsFromRunStats(d.derived.cost.rows);
26
+ window.AmicusRender.renderSeats(A.$('seats-body'), seats, A.state.blind, A.labelOf);
27
+ }
28
+
29
+ function renderMatrixPanel() {
30
+ var A = window.AmicusApp;
31
+ window.AmicusMatrix.renderMatrix(A.$('matrix-body'), A.state.detail.derived.matrix, drillIntoJudge);
32
+ }
33
+
34
+ function renderVerdictPanel() {
35
+ var A = window.AmicusApp;
36
+ var d = A.state.detail;
37
+ var chairHost = window.AmicusMatrix.renderVerdict(A.$('verdict-body'), d.derived.verdictPanel, {
38
+ labelOf: A.labelOf,
39
+ isBlind: A.isBlind,
40
+ reportPresent: !!(d.artifacts['report.html'] && d.artifacts['report.html'].present),
41
+ onFold: function () { window.AmicusVerbs.doFold(); },
42
+ onOpenReport: function () { A.invoke('workspace:open-report', A.state.runId); },
43
+ });
44
+ if (d.artifacts['chair-output.md'] && d.artifacts['chair-output.md'].present) {
45
+ A.invoke('workspace:read-artifact', A.state.runId, 'chair-output.md').then(function (res) {
46
+ if (res.text) { window.AmicusMd.renderMdLite(chairHost, res.text, document); }
47
+ });
48
+ } else {
49
+ chairHost.appendChild(window.AmicusRender.el('p', { className: 'empty-note' }, ['chair-output.md not written yet']));
50
+ }
51
+ }
52
+
53
+ // ---- lazy prose panels (spec §5.2: load on first open, cache) ---------
54
+ // ⚠️ DE-ROT (F09): a NEW toggle listener stacking on every renderDetail() call is the bug this
55
+ // shape exists to avoid — see wireLazyPanels()/proseLoader() below. Register the three
56
+ // listeners ONCE at boot (workspace-app.js's boot block calls proseLoader per panel id) and
57
+ // dispatch through this module-level `loaders` map, which renderDetail (via wireLazyPanels)
58
+ // overwrites per run.
59
+ //
60
+ // ⚠️ PRE-FLIGHT (P4): the load is AWAITABLE — drillIntoJudge needs to know when it has
61
+ // settled (the old code guessed with setTimeout(render, 300), which could fire before an
62
+ // unbounded N-artifact IPC round trip finished and silently render nothing). loadPanel()
63
+ // is idempotent per panel id and returns its in-flight promise; both the promise cache
64
+ // (`loading`) and the per-run spec (`loaders`) are keyed by panel id and cleared/overwritten
65
+ // by wireLazyPanels() on every run-open — that clearing is what stops F09's stale-run
66
+ // artifact requests.
67
+ var loaders = {}; // panelId -> {bodyId, files} (rewritten per run by wireLazyPanels)
68
+ var loading = {}; // panelId -> Promise (cleared per run by wireLazyPanels)
69
+
70
+ function loadPanel(panelId, bodyId, files) {
71
+ var A = window.AmicusApp;
72
+ if (loading[panelId]) { return loading[panelId]; }
73
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): this is the third
74
+ // instance of the F09 class of bug (a stale async response overwriting shared DOM after
75
+ // the user has navigated away) — already fixed once for the toggle-listener stack (F09
76
+ // itself) and once for the fire-and-forget debate.json fetch in workspace-app.js (guards
77
+ // with `if (state.runId !== runId) return;`). wireLazyPanels() clearing `loading[panelId]`
78
+ // on every run switch permits a NEW request to be issued, but never fenced the PRIOR
79
+ // request's eventual resolution — open reviews-panel on run A, switch to run B (which
80
+ // issues its own request), and A's response — however late — used to overwrite whatever
81
+ // B had just rendered. Capture the runId this request was issued for, and guard as the
82
+ // FIRST statement of the completion handler, exactly like the debate.json fix.
83
+ var runId = A.state.runId;
84
+ loading[panelId] = Promise.all(files().map(function (f) {
85
+ return A.invoke('workspace:read-artifact', runId, f.name).then(function (res) {
86
+ return { name: f.name, title: f.title, text: res.text || '', truncated: res.truncated, error: res.error };
87
+ });
88
+ })).then(function (sections) {
89
+ if (A.state.runId !== runId) { return; } // stale: superseded by a later run switch
90
+ window.AmicusRender.renderProseSections(A.$(bodyId), sections.map(function (s) {
91
+ return s.error ? { name: s.name, title: s.title, error: s.name + ' — ' + s.error } : s;
92
+ }));
93
+ A.$(panelId).dataset.loaded = '1'; // display/debug marker only — `loading` is the real gate
94
+ });
95
+ return loading[panelId];
96
+ }
97
+
98
+ /** Registered ONCE at boot (per panel id); reads the current run's spec off `loaders`. */
99
+ function proseLoader(panelId) {
100
+ var A = window.AmicusApp;
101
+ var panel = A.$(panelId);
102
+ panel.addEventListener('toggle', function () {
103
+ if (!panel.open) { return; }
104
+ var spec = loaders[panelId];
105
+ if (spec) { loadPanel(panelId, spec.bodyId, spec.files); }
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Rewrites the per-run spec map and drops the previous run's cached load promises — this
111
+ * is precisely what stops F09's stale-run artifact requests. Safe to call on every
112
+ * renderDetail() (run-open and blind-toggle alike); it registers no listeners itself.
113
+ */
114
+ function wireLazyPanels() {
115
+ var A = window.AmicusApp;
116
+ ['reviews-panel', 'bundle-panel', 'judges-panel'].forEach(function (id) {
117
+ var p = A.$(id);
118
+ p.dataset.loaded = '0';
119
+ p.open = false;
120
+ delete loading[id];
121
+ });
122
+ var bench = A.state.detail.run.bench || [];
123
+ var debated = !!A.state.detail.run.debate;
124
+ // ⚠️ CODE REVIEW (round 2, finding 2): readRunArtifact's error for a genuinely-missing
125
+ // artifact is NOT translated into a friendly "not written yet" note anywhere in this
126
+ // read path — it lands in the panel verbatim, absolute host path and all. `run.debate` is
127
+ // seeded on run.json's FIRST write, so it's truthy on every --debate run, including ones
128
+ // where the re-vote wave never actually ran (no contested findings, cost ceiling, abort) —
129
+ // requesting revote-<model>.md speculatively in that (near-certain) case means one ugly
130
+ // error row per bench model for a condition that isn't an error at all. run-detail.js
131
+ // already computes a presence manifest (state.detail.artifacts) for exactly these
132
+ // allowlisted names via fs.statSync — filter on it instead of requesting known-absent
133
+ // files. Applies to review-/judge- too (the same latent gap, just plan-mandated rather
134
+ // than new).
135
+ var artifacts = A.state.detail.artifacts || {};
136
+ function present(name) { return !!(artifacts[name] && artifacts[name].present); }
137
+ loaders['reviews-panel'] = { bodyId: 'reviews-body', files: function () {
138
+ return bench.map(function (m) {
139
+ var label = A.state.labelByModel[m];
140
+ return { name: 'review-' + sanitizeName(m) + '.md', title: (A.state.blind && label ? label : m) };
141
+ }).filter(function (f) { return present(f.name); });
142
+ } };
143
+ loaders['bundle-panel'] = { bodyId: 'bundle-body', files: function () {
144
+ return [{ name: 'bundle-stage2.md', title: 'bundle-stage2.md (verbatim)' }];
145
+ } };
146
+ loaders['judges-panel'] = { bodyId: 'judges-body', files: function () {
147
+ var files = bench.map(function (m) {
148
+ var label = A.state.labelByModel[m];
149
+ return { name: 'judge-' + sanitizeName(m) + '.md', title: 'Judge ' + (A.state.blind && label ? label : m) };
150
+ });
151
+ if (debated) {
152
+ // ⚠️ DE-ROT (F38): on a --debate run, a matrix dispute cell can be a RE-VOTE whose
153
+ // prose lives in revote-<model>.md (not judge-<model>.md). Included per bench model
154
+ // like judge-*.md above, but — per the presence filter — only when the manifest
155
+ // confirms the file actually exists (see the code-review note above `present()`).
156
+ // ⚠️ CODE REVIEW (round 2, finding 3): this title is new code (unlike the review-/
157
+ // judge- titles above, which mirror the brief verbatim), so it goes through
158
+ // AmicusRender.display() — the single blind-flip definition — rather than adding a
159
+ // fourth hand-rolled copy of the same ternary.
160
+ files = files.concat(bench.map(function (m) {
161
+ var label = A.state.labelByModel[m];
162
+ return { name: 'revote-' + sanitizeName(m) + '.md', title: 'Re-vote ' + window.AmicusRender.display({ model: m, label: label }, A.state.blind) };
163
+ }));
164
+ }
165
+ return files.filter(function (f) { return present(f.name); });
166
+ } };
167
+ }
168
+
169
+ // ⚠️ DE-ROT (F38): on a --debate run the FINAL tally.json is rebuilt from the debate's
170
+ // replaced adjudications, so a matrix `dispute` cell can be a re-vote — gate per
171
+ // (judge, findingId), not per run: a judge gets ONE re-vote leg covering only the ids it
172
+ // actually re-voted; every other dispute cell still belongs to judge-*.md. debate.json's
173
+ // `revotes[]` is keyed on the bench ALIAS (same key revote-*.md and state.labelByModel use),
174
+ // so no filename inversion is needed. Returns the settle promise so callers (and tests) can
175
+ // await the highlight instead of guessing when it lands.
176
+ function drillIntoJudge(judgePair, findingId) {
177
+ var A = window.AmicusApp;
178
+ var panel = A.$('judges-panel');
179
+ panel.open = true;
180
+ var spec = loaders['judges-panel'];
181
+ if (!spec) { return Promise.resolve(); }
182
+ return loadPanel('judges-panel', spec.bodyId, spec.files).then(function () {
183
+ var rv = ((A.state.debate && A.state.debate.revotes) || []).find(function (r) {
184
+ return r.judge === judgePair.model && r.id === findingId;
185
+ });
186
+ var artifactName = rv
187
+ ? 'revote-' + sanitizeName(judgePair.model) + '.md'
188
+ : 'judge-' + sanitizeName(judgePair.model) + '.md';
189
+ var section = A.$('judges-body').querySelector('[data-artifact="' + artifactName + '"]');
190
+ // A genuinely absent artifact is not an error here — the panel renders its own
191
+ // "<file> not written yet" empty state (spec §9, last row).
192
+ if (!section) { return; }
193
+ // ⚠️ CODE REVIEW (round 2, finding 4) + ⚠️ R4 COUNCIL REVIEW (fourth live paid council,
194
+ // major, unanimous): loadPanel() is cached per panel id, so this DOM section is built
195
+ // once and never rebuilt — every drill into this judge re-enters this .then() against
196
+ // the SAME section. The original guard ("skip if the section already has a <mark> /
197
+ // .revote-reason ANYWHERE") stopped a repeat drill into the SAME finding from
198
+ // duplicating the reason paragraph / nesting a second <mark> — but it also permanently
199
+ // wedged a LATER drill into a DIFFERENT finding on the same judge, since the stale
200
+ // mark from the first finding trips the same "already annotated" check forever.
201
+ // Track which finding is CURRENTLY highlighted on this section instead: a repeat drill
202
+ // on that same finding is a no-op (idempotent), while a drill into any other finding
203
+ // clears the previous mark/reason before applying the new one.
204
+ if (section.dataset.drilledFinding === findingId) { return; }
205
+ var staleReason = section.querySelector('.revote-reason');
206
+ if (staleReason) { staleReason.remove(); }
207
+ window.AmicusMatrix.clearHighlight(section);
208
+ if (rv && rv.reason) {
209
+ section.insertBefore(window.AmicusRender.el('p', { className: 'mono revote-reason' }, [rv.reason]), section.children[1] || null);
210
+ }
211
+ window.AmicusMatrix.highlightText(section, findingId);
212
+ section.dataset.drilledFinding = findingId;
213
+ section.scrollIntoView({ block: 'start' });
214
+ });
215
+ }
216
+
217
+ window.AmicusPanels = {
218
+ renderSeatsPanel: renderSeatsPanel,
219
+ renderMatrixPanel: renderMatrixPanel,
220
+ renderVerdictPanel: renderVerdictPanel,
221
+ wireLazyPanels: wireLazyPanels,
222
+ proseLoader: proseLoader,
223
+ drillIntoJudge: drillIntoJudge,
224
+ sanitizeName: sanitizeName,
225
+ };
226
+ })();