amicus 4.3.0 → 4.4.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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,112 @@
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
+ // ⚠️ v4.4.1 A1: 'timed-out' added alongside 'timeout'. Both spellings are real and are written
15
+ // by different producers — see src/observe/live-doc.js:18. Inert for the workspace (a council
16
+ // run.json's status vocabulary is aborted|complete|error|partial), carried for byte-identity.
17
+ var TERMINAL_STATUSES = ['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout'];
18
+
19
+ // ⚠️ DE-ROT (F41): STAGE_LABELS must be mirrored here too. The live loop labels stages that
20
+ // START AFTER the run was opened, and those names are absent from the frozen derived.stageRail,
21
+ // so without this table every post-open stage renders its raw name ("tally-final", "debate-revote").
22
+ var STAGE_LABELS = {
23
+ stage1: 'Stage 1 — independent review',
24
+ stage2: 'Stage 2 — peer cross-review',
25
+ 'debate-defense': 'Debate — defense',
26
+ 'debate-revote': 'Debate — re-vote',
27
+ 'tally-provisional': 'Tally (provisional)',
28
+ tally: 'Tally',
29
+ 'tally-final': 'Tally (final)',
30
+ chair: 'Chair synthesis',
31
+ verdict: 'Verdict',
32
+ };
33
+
34
+ /** Poll cadence per spec §4.3: 1.5s visible+focused, 5s otherwise, stop at terminal. */
35
+ function pollDelay(state) {
36
+ if (state.terminal) { return null; }
37
+ return (state.visible && state.focused) ? 1500 : 5000;
38
+ }
39
+
40
+ /** Blind default per spec resolved Q2: ON live, OFF terminal. */
41
+ function defaultBlind(status) {
42
+ return TERMINAL_STATUSES.indexOf(status) === -1;
43
+ }
44
+
45
+ function dash(v) {
46
+ return (v === null || v === undefined || v === '') ? '—' : String(v);
47
+ }
48
+
49
+ /**
50
+ * Display cells for one seat row, in the seats-table column order:
51
+ * [name, role, status, stage, msgs, tokens in/out, cost, last activity, stalled]
52
+ */
53
+ function seatCells(seat, blindOn, labelOf) {
54
+ // ⚠️ DE-ROT (F36): key the lookup on the council ALIAS, never on `seat.model`. labelMap's
55
+ // values are aliases; a LIVE leg's `model` is the resolved executable id, so labelOf(model)
56
+ // misses and Blind ON renders the real model. `modelInput` is the alias Task 0.5 stamps per
57
+ // leg (terminal rows from runStats are already alias-only, so the fallback is exact there).
58
+ var alias = seat.modelInput || seat.model;
59
+ var label = labelOf ? labelOf(alias) : null;
60
+ // ⚠️ v4.4.1 RN-9: this hand-rolled flip is LOAD-BEARING — do NOT "fix" it into
61
+ // AmicusRender.display({model: seat.model, label: label}, blindOn). The other two copies
62
+ // (workspace-panels.js's review/judge titles) were routed through display() because their
63
+ // pair's `model` IS the identity they must print. Here it is not: the seat's printable
64
+ // identity is the council ALIAS resolved on the line above (F36 — a live leg's `seat.model`
65
+ // is the RESOLVED executable id, e.g. `google/gemini-2.5-pro`, which labelMap never keys on),
66
+ // so display()'s blind-OFF arm would print the resolved id and undo F36. This module is also
67
+ // node-tested with NO DOM and no window.AmicusRender to call. Deliberate third copy.
68
+ var name = blindOn && label ? label : alias;
69
+ var tokens = (seat.tokensIn === null || seat.tokensIn === undefined) &&
70
+ (seat.tokensOut === null || seat.tokensOut === undefined)
71
+ ? '—'
72
+ : dash(seat.tokensIn) + '/' + dash(seat.tokensOut);
73
+ return [
74
+ dash(name),
75
+ dash(seat.role),
76
+ dash(seat.status),
77
+ dash(seat.stage),
78
+ dash(seat.messages),
79
+ tokens,
80
+ dash(seat.costDisplay),
81
+ // ⚠️ DE-ROT (F35): pass-through of an ISO timestamp, NOT a relative string. renderSeats
82
+ // formats it with relTime() before calling in — live-model.js is node-tested and loads
83
+ // before workspace-render.js, so it cannot reach relTime itself.
84
+ dash(seat.lastActivity),
85
+ seat.stalled ? '⏳ stalled' : '',
86
+ ];
87
+ }
88
+
89
+ /** Terminal fallback: derive seat-shaped rows from tally runStats cost rows. */
90
+ function seatsFromRunStats(costRows) {
91
+ return (costRows || []).map(function (r) {
92
+ return {
93
+ // ⚠️ DE-ROT (F37): composite id — a v4.1 `--debate` run emits extra runStats rows for
94
+ // the SAME bench alias (role 'rebuttal'/'revote', src/council/debate.js:88-96). With no
95
+ // id, renderSeats keys on model and the re-vote row silently overwrites the seat row.
96
+ // (If the panel is meant to be bench-only, filter to seat/critic/lens:* instead and
97
+ // leave rebuttal/revote spend to the cost table — but say which; do not leave it implied.)
98
+ id: r.model + ':' + (r.role || 'seat'),
99
+ model: r.model, role: r.role || null, status: r.status || null,
100
+ stage: null, messages: null, tokensIn: null, tokensOut: null,
101
+ costDisplay: r.costDisplay || null, lastActivity: null, latestPreview: null,
102
+ stalled: false,
103
+ };
104
+ });
105
+ }
106
+
107
+ // ⚠️ DE-ROT (F41): STAGE_LABELS is exported so applyLive() can label post-open stages.
108
+ var api = { pollDelay: pollDelay, seatCells: seatCells, seatsFromRunStats: seatsFromRunStats,
109
+ defaultBlind: defaultBlind, dash: dash, TERMINAL_STATUSES: TERMINAL_STATUSES, STAGE_LABELS: STAGE_LABELS };
110
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
111
+ if (typeof window !== 'undefined') { window.AmicusLive = api; }
112
+ })();
@@ -0,0 +1,163 @@
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
+ /**
19
+ * Split one line into literal / inline-code segments in a SINGLE linear pass.
20
+ *
21
+ * ⚠️ v4.4.1 A1/D1 (Confirmed 4/4), with its SEVERITY CLAIM CORRECTED. The
22
+ * previous form re-`exec`ed a freshly sliced `rest` each iteration
23
+ * (`rest = rest.slice(...)`) — quadratic as WRITTEN, but not as it ran. V8's
24
+ * `String.prototype.slice` returns a SlicedString (parent pointer + offset),
25
+ * not a copy, so the old code measured ~8 ms at artifact-guard.js's 200 KB cap
26
+ * and scaled LINEARLY (~2x per doubling out to 2.4 MB). A deliberately-copying
27
+ * control took 78,232 ms at that same 200 KB — that is the freeze the finding
28
+ * described, and it is not what shipped. Numbers:
29
+ * `.superpowers/sdd/task-10-report.md:127-148`.
30
+ *
31
+ * What the rewrite removes is therefore a silent dependence on an undocumented
32
+ * engine optimisation that nothing here states or tests — a latent PORTABILITY
33
+ * hazard on any engine without sliced strings — and not a live performance
34
+ * defect or an exploitable-DoS-that-was. The `lastIndex` cursor walks the
35
+ * ORIGINAL string once and never copies a tail, so linearity is now a property
36
+ * of the algorithm rather than of the engine.
37
+ *
38
+ * Output is identical to the old function for every input: the pattern is
39
+ * context-free — no `^`, `\b`, lookaround or backreference — so a /g scan
40
+ * resuming at `lastIndex` lands on exactly the same match positions that
41
+ * re-`exec`ing the remainder did. The three boundary cases match too: no
42
+ * match at all yields one literal segment, a trailing match yields no empty
43
+ * tail segment, and empty input yields [].
44
+ *
45
+ * The regex is constructed per call and deliberately NOT hoisted to module
46
+ * scope: a /g regex carries mutable `lastIndex`, so one shared instance would
47
+ * leak cursor state between calls and silently drop spans.
48
+ */
49
+ function parseInline(text) {
50
+ var out = [];
51
+ var s = String(text);
52
+ var re = /`([^`\n]+)`/g;
53
+ var pos = 0;
54
+ var m;
55
+ while ((m = re.exec(s)) !== null) {
56
+ if (m.index > pos) { out.push({ code: false, text: s.slice(pos, m.index) }); }
57
+ out.push({ code: true, text: m[1] });
58
+ pos = re.lastIndex;
59
+ }
60
+ if (pos < s.length) { out.push({ code: false, text: s.slice(pos) }); }
61
+ return out;
62
+ }
63
+
64
+ function parseMdLite(text) {
65
+ var lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
66
+ var blocks = [];
67
+ var i = 0;
68
+ while (i < lines.length) {
69
+ var line = lines[i];
70
+ if (/^```/.test(line)) {
71
+ var buf = [];
72
+ i += 1;
73
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i += 1; }
74
+ i += 1; // closing fence (or EOF)
75
+ blocks.push({ t: 'code', text: buf.join('\n') });
76
+ continue;
77
+ }
78
+ var h = H_RE.exec(line);
79
+ // ⚠️ v4.4.1 D3: trim the heading text. H_RE's `\s+` eats the run of
80
+ // whitespace after the hashes, but `(.*)$` keeps everything to end of
81
+ // line — so `# Title ` rendered a heading with trailing blanks baked
82
+ // into its text node.
83
+ if (h) { blocks.push({ t: 'h', level: h[1].length, text: h[2].trim() }); i += 1; continue; }
84
+ if (UL_RE.test(line)) {
85
+ var ul = [];
86
+ while (i < lines.length && UL_RE.test(lines[i])) { ul.push(lines[i].replace(UL_RE, '')); i += 1; }
87
+ blocks.push({ t: 'ul', items: ul });
88
+ continue;
89
+ }
90
+ if (OL_RE.test(line)) {
91
+ var ol = [];
92
+ while (i < lines.length && OL_RE.test(lines[i])) { ol.push(lines[i].replace(OL_RE, '')); i += 1; }
93
+ blocks.push({ t: 'ol', items: ol });
94
+ continue;
95
+ }
96
+ if (!line.trim()) { i += 1; continue; }
97
+ var p = [line];
98
+ i += 1;
99
+ while (i < lines.length && lines[i].trim() &&
100
+ !/^```/.test(lines[i]) && !H_RE.test(lines[i]) &&
101
+ !UL_RE.test(lines[i]) && !OL_RE.test(lines[i])) {
102
+ p.push(lines[i]);
103
+ i += 1;
104
+ }
105
+ blocks.push({ t: 'p', text: p.join(' ') });
106
+ }
107
+ return blocks;
108
+ }
109
+
110
+ function applyInline(el, segs, doc) {
111
+ for (var i = 0; i < segs.length; i++) {
112
+ if (segs[i].code) {
113
+ var c = doc.createElement('code');
114
+ c.textContent = segs[i].text;
115
+ el.appendChild(c);
116
+ } else {
117
+ el.appendChild(doc.createTextNode(segs[i].text));
118
+ }
119
+ }
120
+ }
121
+
122
+ /** Render prose into container. Panels use h3–h6 so page structure wins. */
123
+ function renderMdLite(container, text, doc) {
124
+ var d = doc || document;
125
+ container.textContent = '';
126
+ var blocks = parseMdLite(text);
127
+ for (var i = 0; i < blocks.length; i++) {
128
+ var b = blocks[i];
129
+ var el;
130
+ if (b.t === 'h') {
131
+ // ⚠️ v4.4.1 D4: the `Math.min(6, …)` that used to wrap this was
132
+ // unreachable — H_RE's `#{1,4}` bounds level to 1–4, so the tag is
133
+ // always h3–h6 and the clamp could never fire. Dead defensive code is
134
+ // worse than none here: it made the h6 ceiling look enforced when the
135
+ // real guarantee lives in H_RE. If that `#{1,4}` is ever widened, THIS
136
+ // line must widen with it — h7 is not an element. The
137
+ // `'#'.repeat(4) + ' X'` → h6 test pins the true boundary.
138
+ el = d.createElement('h' + (b.level + 2));
139
+ applyInline(el, parseInline(b.text), d);
140
+ } else if (b.t === 'code') {
141
+ el = d.createElement('pre');
142
+ var code = d.createElement('code');
143
+ code.textContent = b.text;
144
+ el.appendChild(code);
145
+ } else if (b.t === 'ul' || b.t === 'ol') {
146
+ el = d.createElement(b.t);
147
+ for (var j = 0; j < b.items.length; j++) {
148
+ var li = d.createElement('li');
149
+ applyInline(li, parseInline(b.items[j]), d);
150
+ el.appendChild(li);
151
+ }
152
+ } else {
153
+ el = d.createElement('p');
154
+ applyInline(el, parseInline(b.text), d);
155
+ }
156
+ container.appendChild(el);
157
+ }
158
+ }
159
+
160
+ var api = { parseMdLite: parseMdLite, parseInline: parseInline, renderMdLite: renderMdLite };
161
+ if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
162
+ if (typeof window !== 'undefined') { window.AmicusMd = api; }
163
+ })();
@@ -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
+ })();