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,111 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="Content-Security-Policy"
6
+ content="default-src 'none'; script-src file:; style-src file: 'unsafe-inline'; font-src file:; img-src file: data:">
7
+ <title>Amicus Council Workspace</title>
8
+ <link rel="stylesheet" href="../../src/design/tokens.css">
9
+ <link rel="stylesheet" href="./workspace.css">
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <aside id="rail" aria-label="Council runs">
14
+ <header class="rail-head">
15
+ <span class="rail-title">Council runs</span>
16
+ <span id="rail-project" class="rail-project mono"></span>
17
+ </header>
18
+ <ul id="run-list" tabindex="0" aria-label="Run list"></ul>
19
+ </aside>
20
+
21
+ <main id="detail">
22
+ <div id="empty-state" class="empty-state">Select a council run</div>
23
+
24
+ <div id="run-view" hidden>
25
+ <header id="run-header">
26
+ <div class="run-head-row">
27
+ <h1 id="run-title" class="mono"></h1>
28
+ <div id="run-chips" class="chips"></div>
29
+ </div>
30
+ <div class="run-head-row">
31
+ <div id="cost-gauge" class="gauge" role="meter" aria-label="Run cost vs ceiling">
32
+ <div id="cost-gauge-fill" class="gauge-fill"></div>
33
+ <span id="cost-gauge-text" class="gauge-text mono"></span>
34
+ </div>
35
+ <label class="blind-label">
36
+ <input type="checkbox" id="blind-toggle"> Blind mode
37
+ </label>
38
+ <button id="abort-btn" class="btn danger" hidden>Abort run…</button>
39
+ </div>
40
+ </header>
41
+
42
+ <div id="banner" class="banner" hidden></div>
43
+
44
+ <nav id="stage-rail" class="stage-rail" aria-label="Stages"></nav>
45
+
46
+ <section id="seats-panel" class="panel" aria-label="Seats">
47
+ <h2 class="panel-title">Seats</h2>
48
+ <table class="table">
49
+ <thead>
50
+ <tr><th>Seat</th><th>Role</th><th>Status</th><th>Stage</th><th>Msgs</th>
51
+ <th>Tokens in/out</th><th>Cost</th><th>Last activity</th><th></th></tr>
52
+ </thead>
53
+ <tbody id="seats-body"></tbody>
54
+ </table>
55
+ </section>
56
+
57
+ <details id="reviews-panel" class="panel">
58
+ <summary class="panel-title">Stage-1 reviews</summary>
59
+ <div id="reviews-body" class="prose-host"></div>
60
+ </details>
61
+
62
+ <details id="bundle-panel" class="panel">
63
+ <summary class="panel-title">Peer-review packet (verbatim)</summary>
64
+ <div id="bundle-body" class="prose-host"></div>
65
+ </details>
66
+
67
+ <details id="judges-panel" class="panel">
68
+ <summary class="panel-title">Judge outputs</summary>
69
+ <div id="judges-body" class="prose-host"></div>
70
+ </details>
71
+
72
+ <section id="matrix-panel" class="panel" aria-label="Adjudication matrix">
73
+ <h2 class="panel-title">Adjudication matrix</h2>
74
+ <div id="matrix-body"></div>
75
+ </section>
76
+
77
+ <section id="verdict-panel" class="panel" aria-label="Verdict">
78
+ <h2 class="panel-title">Verdict</h2>
79
+ <div id="verdict-body"></div>
80
+ </section>
81
+
82
+ <section id="cost-panel" class="panel" aria-label="Cost">
83
+ <h2 class="panel-title">Cost by seat</h2>
84
+ <div id="cost-body"></div>
85
+ </section>
86
+ </div>
87
+ </main>
88
+ </div>
89
+
90
+ <div id="dialog-abort" class="dialog-backdrop" hidden>
91
+ <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-abort-title">
92
+ <h2 id="dialog-abort-title">Abort this council run?</h2>
93
+ <p>The active wave and solo legs will be aborted via the engine's abort path. Artifacts already on disk stay browsable.</p>
94
+ <div class="dialog-actions">
95
+ <button id="dialog-abort-cancel" class="btn">Cancel</button>
96
+ <button id="dialog-abort-confirm" class="btn danger">Abort run</button>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <script src="./md-lite.js"></script>
102
+ <script src="./live-model.js"></script>
103
+ <script src="./workspace-render.js"></script>
104
+ <script src="./workspace-matrix.js"></script>
105
+ <!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
106
+ Load order matters: workspace-app.js boots last and owns `state`. -->
107
+ <script src="./workspace-panels.js"></script>
108
+ <script src="./workspace-verbs.js"></script>
109
+ <script src="./workspace-app.js"></script>
110
+ </body>
111
+ </html>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Council Workspace — pure renderer-side view logic (poll cadence, seat row
3
+ * cells, blind default). No DOM here: node-tested via the module guard.
4
+ * TERMINAL_STATUSES mirrors src/workspace/run-detail.js (the renderer cannot
5
+ * require across the sandbox; keep the two lists in sync).
6
+ */
7
+ (function () {
8
+ 'use strict';
9
+
10
+ // ⚠️ DE-ROT (F26): was a 5-name list — missing 'crashed' and 'idle-timeout', so a crashed
11
+ // or idle-timed-out run would poll forever and never flip to its terminal rendering.
12
+ // Must stay byte-identical to src/workspace/run-detail.js TERMINAL_STATUSES, which itself
13
+ // mirrors the shipped src/observe/live-doc.js TERMINAL set. A drift pin asserts this.
14
+ var TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout'];
15
+
16
+ // ⚠️ DE-ROT (F41): STAGE_LABELS must be mirrored here too. The live loop labels stages that
17
+ // START AFTER the run was opened, and those names are absent from the frozen derived.stageRail,
18
+ // so without this table every post-open stage renders its raw name ("tally-final", "debate-revote").
19
+ var STAGE_LABELS = {
20
+ stage1: 'Stage 1 — independent review',
21
+ stage2: 'Stage 2 — peer cross-review',
22
+ 'debate-defense': 'Debate — defense',
23
+ 'debate-revote': 'Debate — re-vote',
24
+ 'tally-provisional': 'Tally (provisional)',
25
+ tally: 'Tally',
26
+ 'tally-final': 'Tally (final)',
27
+ chair: 'Chair synthesis',
28
+ verdict: 'Verdict',
29
+ };
30
+
31
+ /** Poll cadence per spec §4.3: 1.5s visible+focused, 5s otherwise, stop at terminal. */
32
+ function pollDelay(state) {
33
+ if (state.terminal) { return null; }
34
+ return (state.visible && state.focused) ? 1500 : 5000;
35
+ }
36
+
37
+ /** Blind default per spec resolved Q2: ON live, OFF terminal. */
38
+ function defaultBlind(status) {
39
+ return TERMINAL_STATUSES.indexOf(status) === -1;
40
+ }
41
+
42
+ function dash(v) {
43
+ return (v === null || v === undefined || v === '') ? '—' : String(v);
44
+ }
45
+
46
+ /**
47
+ * Display cells for one seat row, in the seats-table column order:
48
+ * [name, role, status, stage, msgs, tokens in/out, cost, last activity, stalled]
49
+ */
50
+ function seatCells(seat, blindOn, labelOf) {
51
+ // ⚠️ DE-ROT (F36): key the lookup on the council ALIAS, never on `seat.model`. labelMap's
52
+ // values are aliases; a LIVE leg's `model` is the resolved executable id, so labelOf(model)
53
+ // misses and Blind ON renders the real model. `modelInput` is the alias Task 0.5 stamps per
54
+ // leg (terminal rows from runStats are already alias-only, so the fallback is exact there).
55
+ var alias = seat.modelInput || seat.model;
56
+ var label = labelOf ? labelOf(alias) : null;
57
+ var name = blindOn && label ? label : alias;
58
+ var tokens = (seat.tokensIn === null || seat.tokensIn === undefined) &&
59
+ (seat.tokensOut === null || seat.tokensOut === undefined)
60
+ ? '—'
61
+ : dash(seat.tokensIn) + '/' + dash(seat.tokensOut);
62
+ return [
63
+ dash(name),
64
+ dash(seat.role),
65
+ dash(seat.status),
66
+ dash(seat.stage),
67
+ dash(seat.messages),
68
+ tokens,
69
+ dash(seat.costDisplay),
70
+ // ⚠️ DE-ROT (F35): pass-through of an ISO timestamp, NOT a relative string. renderSeats
71
+ // formats it with relTime() before calling in — live-model.js is node-tested and loads
72
+ // before workspace-render.js, so it cannot reach relTime itself.
73
+ dash(seat.lastActivity),
74
+ seat.stalled ? '⏳ stalled' : '',
75
+ ];
76
+ }
77
+
78
+ /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
79
+ function seatsFromRunStats(costRows) {
80
+ return (costRows || []).map(function (r) {
81
+ return {
82
+ // ⚠️ DE-ROT (F37): composite id — a v4.1 `--debate` run emits extra runStats rows for
83
+ // the SAME bench alias (role 'rebuttal'/'revote', src/council/debate.js:88-96). With no
84
+ // id, renderSeats keys on model and the re-vote row silently overwrites the seat row.
85
+ // (If the panel is meant to be bench-only, filter to seat/critic/lens:* instead and
86
+ // leave rebuttal/revote spend to the cost table — but say which; do not leave it implied.)
87
+ id: r.model + ':' + (r.role || 'seat'),
88
+ model: r.model, role: r.role || null, status: r.status || null,
89
+ stage: null, messages: null, tokensIn: null, tokensOut: null,
90
+ costDisplay: r.costDisplay || null, lastActivity: null, latestPreview: null,
91
+ stalled: false,
92
+ };
93
+ });
94
+ }
95
+
96
+ // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
97
+ var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
98
+ defaultBlind: defaultBlind, dash: dash, TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
99
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
100
+ if (typeof window !== 'undefined') { window.AmicusLive = api; }
101
+ })();
@@ -0,0 +1,119 @@
1
+ /**
2
+ * markdown-lite — dependency-free renderer for untrusted model prose
3
+ * (v4.4 §4.2, resolved Q7). Supported: #–#### headings, - / * bullets,
4
+ * numbered items, ``` fences, `inline code`, paragraphs. EVERY string lands
5
+ * in the DOM via textContent / createTextNode — no HTML-string injection, no
6
+ * HTML passthrough, no links. (⚠️ DE-ROT F32: wording avoids the banned token —
7
+ * see the note above the block.) The parser is pure (node-tested); the applier takes
8
+ * an explicit document. Loaded as a plain script (window.AmicusMd) and as a
9
+ * CommonJS module (jest).
10
+ */
11
+ (function () {
12
+ 'use strict';
13
+
14
+ var H_RE = /^(#{1,4})\s+(.*)$/;
15
+ var UL_RE = /^\s*[-*]\s+/;
16
+ var OL_RE = /^\s*\d+[.)]\s+/;
17
+
18
+ function parseInline(text) {
19
+ var out = [];
20
+ var rest = String(text);
21
+ while (rest.length) {
22
+ var m = /`([^`\n]+)`/.exec(rest);
23
+ if (!m) { out.push({ code: false, text: rest }); break; }
24
+ if (m.index > 0) { out.push({ code: false, text: rest.slice(0, m.index) }); }
25
+ out.push({ code: true, text: m[1] });
26
+ rest = rest.slice(m.index + m[0].length);
27
+ }
28
+ return out;
29
+ }
30
+
31
+ function parseMdLite(text) {
32
+ var lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
33
+ var blocks = [];
34
+ var i = 0;
35
+ while (i < lines.length) {
36
+ var line = lines[i];
37
+ if (/^```/.test(line)) {
38
+ var buf = [];
39
+ i += 1;
40
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i += 1; }
41
+ i += 1; // closing fence (or EOF)
42
+ blocks.push({ t: 'code', text: buf.join('\n') });
43
+ continue;
44
+ }
45
+ var h = H_RE.exec(line);
46
+ if (h) { blocks.push({ t: 'h', level: h[1].length, text: h[2] }); i += 1; continue; }
47
+ if (UL_RE.test(line)) {
48
+ var ul = [];
49
+ while (i < lines.length && UL_RE.test(lines[i])) { ul.push(lines[i].replace(UL_RE, '')); i += 1; }
50
+ blocks.push({ t: 'ul', items: ul });
51
+ continue;
52
+ }
53
+ if (OL_RE.test(line)) {
54
+ var ol = [];
55
+ while (i < lines.length && OL_RE.test(lines[i])) { ol.push(lines[i].replace(OL_RE, '')); i += 1; }
56
+ blocks.push({ t: 'ol', items: ol });
57
+ continue;
58
+ }
59
+ if (!line.trim()) { i += 1; continue; }
60
+ var p = [line];
61
+ i += 1;
62
+ while (i < lines.length && lines[i].trim() &&
63
+ !/^```/.test(lines[i]) && !H_RE.test(lines[i]) &&
64
+ !UL_RE.test(lines[i]) && !OL_RE.test(lines[i])) {
65
+ p.push(lines[i]);
66
+ i += 1;
67
+ }
68
+ blocks.push({ t: 'p', text: p.join(' ') });
69
+ }
70
+ return blocks;
71
+ }
72
+
73
+ function applyInline(el, segs, doc) {
74
+ for (var i = 0; i < segs.length; i++) {
75
+ if (segs[i].code) {
76
+ var c = doc.createElement('code');
77
+ c.textContent = segs[i].text;
78
+ el.appendChild(c);
79
+ } else {
80
+ el.appendChild(doc.createTextNode(segs[i].text));
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Render prose into container. Panels use h3–h6 so page structure wins. */
86
+ function renderMdLite(container, text, doc) {
87
+ var d = doc || document;
88
+ container.textContent = '';
89
+ var blocks = parseMdLite(text);
90
+ for (var i = 0; i < blocks.length; i++) {
91
+ var b = blocks[i];
92
+ var el;
93
+ if (b.t === 'h') {
94
+ el = d.createElement('h' + Math.min(6, b.level + 2));
95
+ applyInline(el, parseInline(b.text), d);
96
+ } else if (b.t === 'code') {
97
+ el = d.createElement('pre');
98
+ var code = d.createElement('code');
99
+ code.textContent = b.text;
100
+ el.appendChild(code);
101
+ } else if (b.t === 'ul' || b.t === 'ol') {
102
+ el = d.createElement(b.t);
103
+ for (var j = 0; j < b.items.length; j++) {
104
+ var li = d.createElement('li');
105
+ applyInline(li, parseInline(b.items[j]), d);
106
+ el.appendChild(li);
107
+ }
108
+ } else {
109
+ el = d.createElement('p');
110
+ applyInline(el, parseInline(b.text), d);
111
+ }
112
+ container.appendChild(el);
113
+ }
114
+ }
115
+
116
+ var api = { parseMdLite: parseMdLite, parseInline: parseInline, renderMdLite: renderMdLite };
117
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
118
+ if (typeof window !== 'undefined') { window.AmicusMd = api; }
119
+ })();
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Council Workspace — application state + wiring (v4.4 §5).
3
+ * Historical rendering in this task; Task 15 adds the live poll loop into
4
+ * the seams marked LIVE; Task 16/17 flip the Abort button + confirm dialog.
5
+ *
6
+ * ⚠️ DE-ROT (F05) / PRE-FLIGHT (P2): this file is the last of the three-way
7
+ * split (workspace-app.js / workspace-panels.js / workspace-verbs.js). It
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.
11
+ */
12
+ (function () {
13
+ 'use strict';
14
+
15
+ var P = window.AmicusPanels;
16
+ var V = window.AmicusVerbs;
17
+
18
+ var invoke = function (ch) {
19
+ var args = Array.prototype.slice.call(arguments, 1);
20
+ return window.amicusWorkspace.invoke.apply(null, [ch].concat(args));
21
+ };
22
+
23
+ var state = {
24
+ runId: null,
25
+ detail: null,
26
+ debate: null, // ⚠️ DE-ROT (F38): parsed debate.json, fetched once per run-open; stays
27
+ // null on a non-debate run, an aborted/skipped debate, or a parse failure —
28
+ // drillIntoJudge's judge-*.md fallback covers all three.
29
+ blind: false,
30
+ labelByModel: {},
31
+ listTimer: null,
32
+ liveTimer: null, // LIVE (Task 15)
33
+ liveEpoch: 0, // LIVE (Task 15): bumped by stopLiveLoop() to invalidate in-flight ticks
34
+ };
35
+
36
+ function labelOf(model) { return state.labelByModel[model] || null; }
37
+ function isBlind() { return state.blind; }
38
+
39
+ function $(id) { return document.getElementById(id); }
40
+
41
+ // ---- run list ----------------------------------------------------------
42
+ function refreshList() {
43
+ return invoke('workspace:list-runs').then(function (rows) {
44
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): thread the
45
+ // CURRENTLY-open run's blind state + label lookup through. labelOf only resolves a
46
+ // label for models that belong to the currently open run, so other rows' chair chips
47
+ // degrade gracefully to the raw id — see renderRunList's own note in workspace-render.js.
48
+ window.AmicusRender.renderRunList($('run-list'), rows, state.runId, openRun, state.blind, labelOf);
49
+ });
50
+ }
51
+
52
+ function startListLoop() {
53
+ if (state.listTimer) { clearInterval(state.listTimer); }
54
+ state.listTimer = setInterval(refreshList, 5000); // spec §4.3
55
+ window.addEventListener('focus', refreshList);
56
+ }
57
+
58
+ // ---- run detail --------------------------------------------------------
59
+ function openRun(runId) {
60
+ state.runId = runId;
61
+ state.debate = null;
62
+ return invoke('workspace:get-run', runId).then(function (detail) {
63
+ state.detail = detail;
64
+ // ⚠️ DE-ROT (F38): debate.json is the re-vote index the matrix drill-in needs — fetched
65
+ // once per run-open (never per render), fire-and-forget. An aborted or cost-ceiling
66
+ // -skipped debate never writes it; a parse failure leaves state.debate null either way,
67
+ // which drillIntoJudge treats as "no re-vote for this (judge, id)" and falls back to
68
+ // today's judge-*.md path.
69
+ // ⚠️ CODE REVIEW (round 2, finding 1): this fire-and-forget fetch is exactly the F09
70
+ // class of bug if left unguarded — a stale response from a run the user has since
71
+ // navigated away from must never overwrite the run now open. Capture `runId` and check
72
+ // it's still `state.runId` before writing; a rejection (dead channel, closed window) is
73
+ // caught too, so it never surfaces as an unhandled rejection in the renderer.
74
+ if (detail && detail.run && detail.run.debate) {
75
+ invoke('workspace:read-artifact', runId, 'debate.json').then(function (res) {
76
+ if (state.runId !== runId) { return; }
77
+ try { state.debate = JSON.parse(res.text); } catch (err) { state.debate = null; }
78
+ }).catch(function () {
79
+ if (state.runId !== runId) { return; }
80
+ state.debate = null;
81
+ });
82
+ }
83
+ renderDetail();
84
+ refreshList();
85
+ });
86
+ }
87
+
88
+ function renderDetail() {
89
+ var d = state.detail;
90
+ $('empty-state').hidden = true;
91
+ $('run-view').hidden = false;
92
+ var R = window.AmicusRender;
93
+
94
+ if (!d || d.error || !d.derived) {
95
+ $('run-title').textContent = (d && d.runId) || 'run';
96
+ // ⚠️ Fix-wave item 1: an unreadable run has no derived model behind it, so the Abort
97
+ // button — otherwise left however the PREVIOUS (possibly live, non-terminal) run set it
98
+ // — must not stay enabled here pointed at a run whose detail can't back an abort.
99
+ $('abort-btn').hidden = true;
100
+ R.renderBanner($('banner'),
101
+ 'Run unreadable: ' + ((d && d.error) || (d && d.run && d.run.parseError) || 'unknown') +
102
+ (d && d.runDir ? ' — ' + d.runDir : ''), '');
103
+ return;
104
+ }
105
+
106
+ // blind default: computed ONCE per run-open from status (resolution 9)
107
+ state.blind = window.AmicusLive.defaultBlind(d.run.status);
108
+ $('blind-toggle').checked = state.blind;
109
+ state.labelByModel = {};
110
+ d.derived.names.forEach(function (p) { state.labelByModel[p.model] = p.label; });
111
+
112
+ $('run-title').textContent = d.runId;
113
+ R.renderHeaderChips($('run-chips'), d.run, state.blind, labelOf);
114
+ // v4.4 §8: 6th arg = costExact. A run with an unpriced seat draws an
115
+ // indeterminate gauge and a `≥` readout instead of a confident percentage.
116
+ R.renderGauge($('cost-gauge-fill'), $('cost-gauge-text'),
117
+ d.derived.cost.costAmount, d.derived.cost.maxCost, d.derived.cost.totalDisplay,
118
+ d.derived.cost.costExact !== false);
119
+ R.renderStageRail($('stage-rail'), d.derived.stageRail);
120
+
121
+ renderBanners();
122
+ P.renderSeatsPanel();
123
+ P.renderMatrixPanel();
124
+ P.renderVerdictPanel();
125
+ R.renderCost($('cost-body'), d.derived.cost, state.blind, labelOf);
126
+ P.wireLazyPanels();
127
+ var isTerminal = window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1;
128
+ $('abort-btn').hidden = isTerminal;
129
+ V.startLiveLoop();
130
+ }
131
+
132
+ function renderBanners() {
133
+ var d = state.detail;
134
+ var R = window.AmicusRender;
135
+ if (!d.derived.schemaSupported) {
136
+ R.renderBanner($('banner'), 'This run was written by a different amicus version (schemaVersion ' +
137
+ d.run.schemaVersion + ') — artifacts: ' + d.runDir, 'warn');
138
+ return;
139
+ }
140
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): two distinct bench
141
+ // entries that sanitize to the same artifact name (src/workspace/artifact-guard.js's
142
+ // artifactAllowlist) mean this run directory cannot hold both models' review/judge files
143
+ // under distinct names — drillIntoJudge's artifact lookup would otherwise silently
144
+ // misattribute one model's prose to the other. This is a run-integrity defect the user
145
+ // must see, not a display quirk to smooth over — surfaced ahead of the run.error/reason
146
+ // banner below since it calls into question every judges-panel section's attribution.
147
+ if (d.derived.artifactCollisions && d.derived.artifactCollisions.length) {
148
+ var c = d.derived.artifactCollisions[0];
149
+ R.renderBanner($('banner'),
150
+ 'Run integrity error: bench entries ' + c.models.join(' and ') + ' both sanitize to "' + c.sanitized +
151
+ '" — this run directory cannot distinguish their artifacts, so prose below may be misattributed.', '');
152
+ return;
153
+ }
154
+ // ⚠️ PRE-FLIGHT (P3), caught live by the CDP e2e (Task 18): gating on `d.run.error` ALONE
155
+ // never shows this banner for a `status:'partial'` run — `finalize()` only ever sets
156
+ // `run.error` on the exit-1 path (src/council/run.js:98-100); the exit-2 "degraded" path
157
+ // leaves it null (run-detail.js:83-91). `verdictPanel.reason` (degradedReason()) already
158
+ // covers BOTH cases — exit-1's {code, message} and the partial-run stage-failure/skip
159
+ // sentence — so check for either signal, not run.error alone.
160
+ if (d.run.error || d.derived.verdictPanel.reason) {
161
+ // ⚠️ Code review round 2, finding 2: `d.run.error` is a structured {code, message} object
162
+ // (never a string) — passing it straight to renderBanner set `textContent = <object>`,
163
+ // which coerces to the literal string "[object Object]". `d.derived.verdictPanel.reason`
164
+ // is already the correctly-formatted string (run-detail.js's degradedReason(): "CODE:
165
+ // message" on the exit-1 path, or a stage-failure/skip sentence otherwise) — reuse it
166
+ // rather than re-deriving the same formatting a second time here.
167
+ R.renderBanner($('banner'), d.derived.verdictPanel.reason || 'Run reported an error',
168
+ d.run.status === 'error' ? '' : 'warn');
169
+ return;
170
+ }
171
+ R.renderBanner($('banner'), null);
172
+ }
173
+
174
+ // ---- blind toggle + keyboard ------------------------------------------
175
+ $('blind-toggle').addEventListener('change', function (e) {
176
+ state.blind = !!e.target.checked;
177
+ renderDetail_preserveBlind();
178
+ });
179
+
180
+ function renderDetail_preserveBlind() {
181
+ var keep = state.blind;
182
+ // ⚠️ Fix-wave item 1: renderDetail() itself early-returns safely for an unreadable run
183
+ // (!d || d.error || !d.derived) — but this wrapper used to run past that guard
184
+ // unconditionally, dereferencing the (nonexistent) derived model via renderSeatsPanel()
185
+ // and again on the last line below. Reachable from a typo on the primary documented
186
+ // entry point: `amicus watch <badId> --ui` -> ?runId= boot -> getRunDetail -> error ->
187
+ // the error branch unhides #run-view before the derived-model guard, so the Blind
188
+ // checkbox is live with nothing behind it.
189
+ if (!state.detail || state.detail.error || !state.detail.derived) {
190
+ $('blind-toggle').checked = keep;
191
+ return;
192
+ }
193
+ renderDetail();
194
+ state.blind = keep;
195
+ $('blind-toggle').checked = keep;
196
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): renderDetail() (just
197
+ // called above) resets state.blind to the run's DEFAULT before this function restores the
198
+ // user's chosen `keep` value — renderHeaderChips was painted during that inner call with
199
+ // the (temporarily wrong) default blind state and, unlike seats/matrix/verdict/cost below,
200
+ // was never repainted afterward. Re-render it here too, now that state.blind is correct.
201
+ window.AmicusRender.renderHeaderChips($('run-chips'), state.detail.run, state.blind, labelOf);
202
+ P.renderSeatsPanel();
203
+ P.renderMatrixPanel();
204
+ P.renderVerdictPanel();
205
+ window.AmicusRender.renderCost($('cost-body'), state.detail.derived.cost, state.blind, labelOf);
206
+ }
207
+
208
+ $('run-list').addEventListener('keydown', function (e) {
209
+ var items = Array.prototype.slice.call($('run-list').querySelectorAll('li[data-run-id]'));
210
+ if (!items.length) { return; }
211
+ var idx = items.findIndex(function (li) { return li.dataset.runId === state.runId; });
212
+ if (e.key === 'ArrowDown') { e.preventDefault(); openRun(items[Math.min(items.length - 1, idx + 1)].dataset.runId); }
213
+ if (e.key === 'ArrowUp') { e.preventDefault(); openRun(items[Math.max(0, idx - 1)].dataset.runId); }
214
+ });
215
+
216
+ document.addEventListener('keydown', function (e) {
217
+ if (e.key === 'Escape') { $('dialog-abort').hidden = true; }
218
+ });
219
+
220
+ // ---- boot --------------------------------------------------------------
221
+ // ⚠️ PRE-FLIGHT (P2): publish the namespace BEFORE anything can call into panels/verbs — they
222
+ // read `window.AmicusApp.*` at call time and this file loads last.
223
+ window.AmicusApp = {
224
+ state: state, invoke: invoke, $: $, labelOf: labelOf,
225
+ isBlind: isBlind, openRun: openRun, renderDetail: renderDetail, renderBanners: renderBanners,
226
+ };
227
+
228
+ // ⚠️ PRE-FLIGHT (P4) + DE-ROT (F09): register the three prose `toggle` listeners exactly ONCE,
229
+ // here at boot. wireLazyPanels() (called from renderDetail, on every run-open and blind-toggle)
230
+ // only rewrites the per-run `loaders` spec map from now on — it never adds a listener.
231
+ P.proseLoader('reviews-panel');
232
+ P.proseLoader('bundle-panel');
233
+ P.proseLoader('judges-panel');
234
+
235
+ var boot = new URLSearchParams(window.location.search).get('runId');
236
+ refreshList().then(function () {
237
+ if (boot) { openRun(boot); }
238
+ });
239
+ startListLoop();
240
+ })();