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,271 @@
1
+ /**
2
+ * Council Workspace — DOM painters (run list, header, stage rail, seats,
3
+ * banner, cost). Every string lands via textContent/createTextNode; keyed
4
+ * updates for seat rows (no full re-render per tick, spec §5.2).
5
+ */
6
+ (function () {
7
+ 'use strict';
8
+
9
+ /** Element builder: children that are strings become TEXT nodes (never markup). */
10
+ function el(tag, attrs, children) {
11
+ var node = document.createElement(tag);
12
+ if (attrs) {
13
+ Object.keys(attrs).forEach(function (k) {
14
+ if (k === 'className') { node.className = attrs[k]; }
15
+ else if (k === 'dataset') {
16
+ Object.keys(attrs[k]).forEach(function (d) { node.dataset[d] = attrs[k][d]; });
17
+ } else if (k.indexOf('on') === 0 && typeof attrs[k] === 'function') {
18
+ node.addEventListener(k.slice(2), attrs[k]);
19
+ } else if (k.indexOf('on') === 0) {
20
+ // A non-function on* value must never fall through to setAttribute below — that
21
+ // would write a live inline event-handler attribute (a DOM-injection sink) from data.
22
+ /* skip */
23
+ } else if (attrs[k] === false || attrs[k] === null || attrs[k] === undefined) {
24
+ /* skip */
25
+ } else { node.setAttribute(k, String(attrs[k])); }
26
+ });
27
+ }
28
+ (children || []).forEach(function (c) {
29
+ if (c === null || c === undefined) { return; }
30
+ node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
31
+ });
32
+ return node;
33
+ }
34
+
35
+ function chip(text, kind) {
36
+ return el('span', { className: 'chip ' + (kind || ''), title: text }, [text]);
37
+ }
38
+
39
+ /** Dual-name display flip (blind mode). pair = {model, label}. */
40
+ function display(pair, blind) {
41
+ if (!pair) { return '—'; }
42
+ return blind && pair.label ? pair.label : (pair.model || '—');
43
+ }
44
+
45
+ function relTime(iso) {
46
+ if (!iso) { return '—'; }
47
+ var ms = Date.now() - Date.parse(iso);
48
+ if (!isFinite(ms) || ms < 0) { return iso; }
49
+ var m = Math.floor(ms / 60000);
50
+ if (m < 1) { return 'just now'; }
51
+ if (m < 60) { return m + 'm ago'; }
52
+ var h = Math.floor(m / 60);
53
+ if (h < 48) { return h + 'h ago'; }
54
+ return Math.floor(h / 24) + 'd ago';
55
+ }
56
+
57
+ /** display() for a bare model id, tolerating a missing labelOf (defaults to no label known). */
58
+ function displayModel(model, blindOn, labelOf) {
59
+ return display({ model: model, label: labelOf ? labelOf(model) : null }, blindOn);
60
+ }
61
+
62
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): renderRunList used to
63
+ // interpolate `row.chair` (a raw model id) directly into the run-row-sub line, bypassing
64
+ // display() — every other identity surface (seats, cost rows, revote titles) masks
65
+ // correctly through it. Christian has ruled blind mode DOES mask the roster (see
66
+ // docs/council.md and the v4.4 spec §6/resolved-Q2 amendment) — thread blind + labelOf
67
+ // through so the chair name masks here too. `labelOf` here is the CURRENTLY-OPEN run's
68
+ // labelByModel lookup (workspace-app.js), so it only resolves a label for the row that IS
69
+ // the open run; other rows' chairs have no label data available and degrade gracefully to
70
+ // the raw id — a reading aid, not a security control (§6.1), so best-effort is correct.
71
+ function renderRunList(container, rows, selectedId, onOpen, blindOn, labelOf) {
72
+ container.textContent = '';
73
+ rows.forEach(function (row) {
74
+ if (row.error) {
75
+ container.appendChild(el('li', { className: 'error-row', title: row.pointerPath || '' }, [
76
+ el('div', { className: 'run-row-top' }, [
77
+ el('span', { className: 'mono' }, [row.runId]),
78
+ el('span', {}, ['unreadable']),
79
+ ]),
80
+ el('div', { className: 'run-row-sub' }, [row.runDir || row.error]),
81
+ ]));
82
+ return;
83
+ }
84
+ var li = el('li', {
85
+ className: row.runId === selectedId ? 'selected' : '',
86
+ tabindex: '-1',
87
+ dataset: { runId: row.runId },
88
+ onclick: function () { onOpen(row.runId); },
89
+ }, [
90
+ el('div', { className: 'run-row-top' }, [
91
+ el('span', { className: 'mono' }, [row.runId]),
92
+ chip(row.status, row.status),
93
+ ]),
94
+ el('div', { className: 'run-row-sub' }, [
95
+ relTime(row.startedAt),
96
+ String(row.bench.length) + ' seats',
97
+ 'chair ' + displayModel(row.chair, blindOn, labelOf),
98
+ row.overallVerdict || '',
99
+ row.costDisplay || '',
100
+ ].filter(Boolean).map(function (t) { return el('span', {}, [t]); })),
101
+ ]);
102
+ container.appendChild(li);
103
+ });
104
+ }
105
+
106
+ // ⚠️ R4 COUNCIL REVIEW (fourth live paid council, major, unanimous): the bench/critic/chair
107
+ // chips interpolated raw model ids directly, bypassing display() — the seat table right
108
+ // below masks correctly, so a live blind run showed an unmasked roster in its own header.
109
+ // Lenses (`run.lenses`) are review-style slugs (e.g. 'skeptic'), never model identities —
110
+ // left unmasked, matching the raw display used elsewhere for non-identity chips.
111
+ function renderHeaderChips(container, run, blindOn, labelOf) {
112
+ container.textContent = '';
113
+ container.appendChild(chip(run.status || 'unknown', run.status));
114
+ (Array.isArray(run.bench) ? run.bench : []).forEach(function (m) {
115
+ container.appendChild(chip(displayModel(m, blindOn, labelOf), ''));
116
+ });
117
+ if (run.critic) { container.appendChild(chip('critic: ' + displayModel(run.critic, blindOn, labelOf), '')); }
118
+ (Array.isArray(run.lenses) ? run.lenses : []).forEach(function (s) {
119
+ container.appendChild(chip('lens: ' + s, ''));
120
+ });
121
+ container.appendChild(chip('chair: ' + displayModel(run.chair, blindOn, labelOf), ''));
122
+ if (run.options && run.options.gateway) { container.appendChild(chip('gw: ' + run.options.gateway, '')); }
123
+ }
124
+
125
+ /**
126
+ * v4.4 §8: `costExact` (optional, defaults TRUE so every pre-v4.4 5-arg call
127
+ * site is unchanged) says whether `costAmount` is the whole bill. When false —
128
+ * i.e. some seat reported no usage at all — a gauge reading "50% of budget" is
129
+ * affirmatively misleading, because the true figure can only be HIGHER. The
130
+ * bar still fills to the known fraction (that much is real), but the gauge is
131
+ * marked `unknown` for the hatched CSS treatment and the readout is prefixed
132
+ * `≥` so the number is never mistaken for a measurement.
133
+ */
134
+ function renderGauge(fillEl, textEl, costAmount, maxCost, totalDisplay, costExact) {
135
+ var gauge = fillEl.parentElement;
136
+ var exact = costExact === undefined ? true : !!costExact;
137
+ var prefix = exact ? '' : '≥ ';
138
+ gauge.classList.toggle('unknown', !exact);
139
+ if (maxCost === null || costAmount === null) {
140
+ fillEl.style.width = '0%';
141
+ gauge.classList.remove('over');
142
+ textEl.textContent = prefix + totalDisplay + (maxCost !== null ? ' / $' + maxCost.toFixed(2) : '');
143
+ return;
144
+ }
145
+ var pct = Math.min(100, (costAmount / maxCost) * 100);
146
+ fillEl.style.width = pct.toFixed(1) + '%';
147
+ gauge.classList.toggle('over', costAmount >= maxCost);
148
+ textEl.textContent = prefix + totalDisplay + ' / $' + maxCost.toFixed(2);
149
+ }
150
+
151
+ function renderStageRail(container, stageRail) {
152
+ container.textContent = '';
153
+ (stageRail || []).forEach(function (s) {
154
+ // Default once and reuse everywhere below — the aria-label used to interpolate the raw
155
+ // `s.status` while className defaulted it, so an entry with no status read "…: undefined".
156
+ var status = s.status || 'pending';
157
+ var mark = status === 'complete' ? '✓ ' : (status === 'running' ? '▶ ' : '· ');
158
+ container.appendChild(el('span', {
159
+ className: 'stage ' + status,
160
+ title: (s.startedAt || '') + (s.completedAt ? ' → ' + s.completedAt : ''),
161
+ 'aria-label': s.label + ': ' + status,
162
+ }, [mark + s.label]));
163
+ });
164
+ }
165
+
166
+ /** Keyed seat rows: update in place per seat id/model; remove leavers. */
167
+ function renderSeats(tbody, seats, blindOn, labelOf) {
168
+ // Build a key -> row map from the existing children ONCE per call, instead of a CSS
169
+ // attribute-selector lookup per seat. `tr[data-key="..."]` requires escaping the key for
170
+ // CSS string-literal syntax; escaping only the query side (as the brief's original code
171
+ // did) while the stored `dataset.key` stays raw means a key containing `"` or `\` can
172
+ // never match its own row — the lookup misses every tick and the row is re-appended
173
+ // forever. A plain object lookup sidesteps the escaping problem entirely.
174
+ var existing = {};
175
+ Array.prototype.slice.call(tbody.children).forEach(function (row) {
176
+ existing[row.dataset.key] = row;
177
+ });
178
+ var seen = {};
179
+ seats.forEach(function (seat) {
180
+ // ⚠️ DE-ROT (F37): `seat.id` is now always set by seatsFromRunStats (`model:role`), so
181
+ // debate rebuttal/revote rows no longer collide with the seat row. The `|| seat.model`
182
+ // fallback covers live seats, whose taskId-derived id is already unique.
183
+ var key = String(seat.id || seat.model);
184
+ seen[key] = true;
185
+ var row = existing[key];
186
+ // ⚠️ DE-ROT (F35): seat.lastActivity is the ISO `leg.lastActivityAt`; format it HERE.
187
+ // seatCells lives in live-model.js, which loads first and has no access to relTime.
188
+ var view = Object.assign({}, seat, {
189
+ lastActivity: seat.lastActivity ? relTime(seat.lastActivity) : null,
190
+ });
191
+ var cells = window.AmicusLive.seatCells(view, blindOn, labelOf);
192
+ if (!row) {
193
+ row = el('tr', { dataset: { key: key } }, cells.map(function (c, i) {
194
+ return el('td', { className: i >= 4 && i <= 6 ? 'num' : (i === 8 ? 'stalled-flag' : '') }, [c]);
195
+ }));
196
+ tbody.appendChild(row);
197
+ return;
198
+ }
199
+ cells.forEach(function (c, i) {
200
+ var td = row.children[i];
201
+ if (td && td.textContent !== c) { td.textContent = c; }
202
+ });
203
+ });
204
+ Array.prototype.slice.call(tbody.children).forEach(function (row) {
205
+ if (!seen[row.dataset.key]) { row.remove(); }
206
+ });
207
+ }
208
+
209
+ function renderBanner(bannerEl, text, kind) {
210
+ if (!text) { bannerEl.hidden = true; bannerEl.textContent = ''; return; }
211
+ bannerEl.hidden = false;
212
+ bannerEl.className = 'banner ' + (kind || '');
213
+ bannerEl.textContent = text;
214
+ }
215
+
216
+ function renderCost(container, cost, blindOn, labelOf) {
217
+ container.textContent = '';
218
+ var head = el('tr', {}, ['Seat', 'Role', 'Status', 'Duration', 'Cost'].map(function (h, i) {
219
+ return el('th', { className: i >= 3 ? 'num' : '' }, [h]);
220
+ }));
221
+ var rows = (cost.rows || []).map(function (r) {
222
+ // Single definition of the blind-mode flip (`display()`, above) — this used to
223
+ // hand-duplicate seatCells' ternary, which meant seatCells' blind-mode test never
224
+ // protected this second surface.
225
+ var name = display({ model: r.model, label: labelOf ? labelOf(r.model) : null }, blindOn);
226
+ var dur = r.durationMs === null ? '—' : Math.round(r.durationMs / 1000) + 's';
227
+ return el('tr', {}, [
228
+ el('td', {}, [name]),
229
+ el('td', {}, [r.role || '—']),
230
+ el('td', {}, [r.status || '—']),
231
+ el('td', { className: 'num' }, [dur]),
232
+ el('td', { className: 'num' }, [r.costDisplay || '—']),
233
+ ]);
234
+ });
235
+ var total = el('tr', {}, [
236
+ el('td', {}, [el('strong', {}, ['Run total'])]),
237
+ el('td', {}, ['']), el('td', {}, ['']), el('td', {}, ['']),
238
+ el('td', { className: 'num' }, [cost.totalDisplay || '—']),
239
+ ]);
240
+ var table = el('table', { className: 'table' }, [el('thead', {}, [head]), el('tbody', {}, rows.concat([total]))]);
241
+ container.appendChild(table);
242
+ }
243
+
244
+ /** Prose panels: one titled section per artifact, markdown-lite rendered. */
245
+ function renderProseSections(container, sections) {
246
+ container.textContent = '';
247
+ sections.forEach(function (s) {
248
+ var host = el('div', { className: 'prose-section', dataset: { artifact: s.name } }, [
249
+ el('h3', {}, [s.title]),
250
+ ]);
251
+ var body = el('div', {}, []);
252
+ if (s.error) {
253
+ body.appendChild(el('p', { className: 'empty-note' }, [s.error]));
254
+ } else {
255
+ window.AmicusMd.renderMdLite(body, s.text, document);
256
+ if (s.truncated) {
257
+ body.appendChild(el('p', { className: 'truncate-note' }, ['Truncated at 200 KB — open the run folder for the full file.']));
258
+ }
259
+ }
260
+ host.appendChild(body);
261
+ container.appendChild(host);
262
+ });
263
+ }
264
+
265
+ window.AmicusRender = {
266
+ el: el, chip: chip, display: display, relTime: relTime, renderRunList: renderRunList,
267
+ renderHeaderChips: renderHeaderChips, renderGauge: renderGauge, renderStageRail: renderStageRail,
268
+ renderSeats: renderSeats, renderBanner: renderBanner, renderCost: renderCost,
269
+ renderProseSections: renderProseSections,
270
+ };
271
+ })();
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Council Workspace — action verbs (v4.4 §5, ⚠️ DE-ROT F05 split of
3
+ * workspace-app.js). `doFold` is the only verb live in this task; Task 15
4
+ * adds the live poll loop (startLiveLoop/stopLiveLoop/applyLive) and Task 16
5
+ * adds the abort confirm dialog (openAbortDialog) into the seams below.
6
+ *
7
+ * Loads BEFORE workspace-app.js, so every function here reads
8
+ * `window.AmicusApp` at CALL time — never captured at this file's own load
9
+ * time, since AmicusApp does not exist until workspace-app.js (last in load
10
+ * order) publishes it.
11
+ */
12
+ (function () {
13
+ 'use strict';
14
+
15
+ // Task 16: a LOCAL `$` for the one-time, module-load-time abort-dialog wiring below —
16
+ // the DOM (unlike window.AmicusApp) already exists at this file's load time, since
17
+ // index.html's script tags sit after all the markup. Never used to read AmicusApp state.
18
+ function $(id) { return document.getElementById(id); }
19
+
20
+ function doFold() {
21
+ var A = window.AmicusApp;
22
+ var btn = A.$('fold-btn');
23
+ A.invoke('workspace:fold', A.state.runId).then(function (res) {
24
+ if (res.ok) {
25
+ btn.textContent = 'Folded ✓';
26
+ btn.disabled = true;
27
+ btn.title = res.already ? 'Already folded this session' : 'Fold written to the launching terminal';
28
+ } else {
29
+ btn.title = res.error || 'fold failed';
30
+ }
31
+ });
32
+ }
33
+
34
+ // ---- live loop (spec §4.3; Task 15) -----------------------------------
35
+ // ⚠️ DE-ROT (F42): stopLiveLoop can only clearTimeout a SCHEDULED tick — it cannot cancel an
36
+ // invoke() already in flight, and the visibilitychange/blur/focus listeners at the bottom of
37
+ // this file re-enter startLiveLoop freely. Without a generation guard those forked chains
38
+ // outlive stopLiveLoop, reassign state.liveTimer (orphaning the timer the event just
39
+ // scheduled), and paint run A's legs into run B after openRun() swaps state.detail underneath
40
+ // them. The epoch + PER-TICK runId pin below are mandatory, not optional hardening.
41
+ function stopLiveLoop() {
42
+ var A = window.AmicusApp;
43
+ A.state.liveEpoch = (A.state.liveEpoch || 0) + 1; // invalidates any in-flight tick
44
+ if (A.state.liveTimer) { clearTimeout(A.state.liveTimer); A.state.liveTimer = null; }
45
+ }
46
+
47
+ function liveState(terminal) {
48
+ return {
49
+ terminal: terminal,
50
+ visible: document.visibilityState === 'visible',
51
+ focused: document.hasFocus(),
52
+ };
53
+ }
54
+
55
+ function startLiveLoop() {
56
+ var A = window.AmicusApp;
57
+ stopLiveLoop();
58
+ var d = A.state.detail;
59
+ if (!d || !d.run || window.AmicusLive.TERMINAL_STATUSES.indexOf(d.run.status) !== -1) { return; }
60
+ var epoch = A.state.liveEpoch; // F42: stopLiveLoop() above just bumped it; this chain owns it
61
+ var tick = function () {
62
+ // F42: pin the id PER TICK and SEND the pinned id — matching the reply against
63
+ // state.runId while the request carried state.runId would still let a post-switch
64
+ // request hit run B.
65
+ var runId = A.state.runId;
66
+ // ⚠️ Code review round 2, finding 3: this MUST be `.then(onFulfilled, onRejected)`, not
67
+ // `.then(onFulfilled).catch(onRejected)`. The two look equivalent but are not: with a
68
+ // trailing `.catch`, a THROW inside onFulfilled (e.g. applyLive dereferencing a malformed
69
+ // payload, or a missing DOM id) is itself routed to the same catch and silently rescheduled
70
+ // — the loop keeps polling at full cadence, paints nothing, and logs nothing, indistinguishable
71
+ // from a healthy live view. With the two-argument form, onRejected only ever sees a
72
+ // REJECTED invoke() (a real IPC/network failure); a throw inside onFulfilled propagates as
73
+ // its own unhandled rejection instead, which surfaces (devtools/console) rather than being
74
+ // absorbed.
75
+ A.invoke('workspace:get-live', runId).then(function (live) {
76
+ if (epoch !== A.state.liveEpoch || runId !== A.state.runId) { return; } // stale chain
77
+ applyLive(live);
78
+ var terminal = !!(live && live.ok && live.terminal);
79
+ if (terminal) {
80
+ stopLiveLoop();
81
+ // ⚠️ Code review round 2, minor: openRun() is fire-and-forget here with no consumer —
82
+ // a rejected final refresh (e.g. the run folder vanished mid-read) must not strand the
83
+ // transient "refreshing…" banner forever, since the loop has already stopped and no
84
+ // later tick will ever repaint it.
85
+ A.openRun(runId).catch(function (err) {
86
+ console.error('workspace live loop: final get-run refresh failed', err);
87
+ window.AmicusRender.renderBanner(A.$('banner'),
88
+ 'Run ended, but refreshing the final details failed — reopen the run to retry.', '');
89
+ });
90
+ return;
91
+ }
92
+ A.state.liveTimer = setTimeout(tick, window.AmicusLive.pollDelay(liveState(false)));
93
+ }, function (err) {
94
+ // A genuinely rejected invoke() (IPC/network failure) — reschedule unless superseded,
95
+ // but log it; silently retrying forever with no trace was the code-review finding.
96
+ if (epoch !== A.state.liveEpoch || runId !== A.state.runId) { return; }
97
+ console.error('workspace live loop: workspace:get-live failed', err);
98
+ A.state.liveTimer = setTimeout(tick, window.AmicusLive.pollDelay(liveState(false)));
99
+ });
100
+ };
101
+ A.state.liveTimer = setTimeout(tick, 0);
102
+ }
103
+
104
+ function applyLive(live) {
105
+ var A = window.AmicusApp;
106
+ var R = window.AmicusRender;
107
+ if (!live || !live.ok) {
108
+ // A1 failure: keep last-known panels; flag the live layer only (spec §9)
109
+ // 'live' (alongside 'info') is a marker class, not a style — see the clear-arm note below.
110
+ R.renderBanner(A.$('banner'), 'live data unavailable' + (live && live.error ? ' — ' + live.error : ''), 'info live');
111
+ return;
112
+ }
113
+ // ⚠️ Code review round 2, minor: was `if (live.seats.length)`, which skips renderSeats
114
+ // entirely for an empty array — so a roster that shrinks to zero between stages (the
115
+ // composed doc's `legs` is only populated while a stage is active) leaves the PREVIOUS
116
+ // stage's rows frozen on screen forever, since renderSeats' own leaver-removal never runs.
117
+ // `live.seats` is always an array per the LiveModel contract (never undefined/null) when
118
+ // `live.ok` is true, so this is simply "always paint," empty roster included.
119
+ if (live.seats) {
120
+ R.renderSeats(A.$('seats-body'), live.seats, A.state.blind, A.labelOf);
121
+ }
122
+ // F42: state.detail can be swapped/absent under a tick — never deref .derived unguarded.
123
+ var derived = A.state.detail && A.state.detail.derived ? A.state.detail.derived : null;
124
+ if (live.stages) {
125
+ // ⚠️ DE-ROT (F41): a raw-name fallback would show the RAW stage name for every stage that
126
+ // starts AFTER run-open (most of them — state.detail is a frozen run-open snapshot).
127
+ // STAGE_LABELS is mirrored onto window.AmicusLive (Task 12/14) for exactly this reason;
128
+ // label from the mirror, not the snapshot.
129
+ // ⚠️ DE-ROT (F40): live stage entries carry no startedAt/completedAt, so reading them off
130
+ // `s` wipes the durable times already on screen on the very first tick. Merge onto the
131
+ // run-open snapshot row instead (a stage that begins mid-session shows no times until the
132
+ // terminal openRun() refresh — status still updates every tick).
133
+ R.renderStageRail(A.$('stage-rail'), live.stages.map(function (s) {
134
+ var prior = (derived ? derived.stageRail.find(function (r) { return r.name === s.name; }) : null) || {};
135
+ return {
136
+ name: s.name,
137
+ label: window.AmicusLive.STAGE_LABELS[s.name] || s.name,
138
+ status: s.status || 'pending',
139
+ startedAt: prior.startedAt || null,
140
+ completedAt: prior.completedAt || null,
141
+ };
142
+ }));
143
+ }
144
+ // ⚠️ DE-ROT (F39): guard on costAmount ALONE. costDisplay is raw formatCost output and can be
145
+ // '?' (source 'unknown') or '—' (null cost); letting either string fall through would
146
+ // overwrite the durable total with a stage-scoped placeholder. The value is ACTIVE-STAGE
147
+ // spend, not a run total — labelled, never sold as one. The durable total returns on the
148
+ // terminal openRun() refresh.
149
+ if (live.costAmount !== null) {
150
+ // v4.4 §8: 6th arg = costExact. A stage carrying an unpriced leg draws the
151
+ // indeterminate gauge + `≥` readout rather than a confident percentage.
152
+ R.renderGauge(A.$('cost-gauge-fill'), A.$('cost-gauge-text'),
153
+ live.costAmount, derived ? derived.cost.maxCost : null,
154
+ (live.costDisplay || '—') + ' (this stage)',
155
+ live.costExact !== false);
156
+ }
157
+ // Dead-run banner: DATA-LAYER flags only (A4) — never GUI heuristics.
158
+ // ⚠️ Task 14 review: flags.crashed really means "errored with a reason" — finalize() maps
159
+ // ANY exit code 1 to status:'error' with run.error set, including a clean, zero-spend,
160
+ // no-legs-ever-launched validation failure. "No leg activity — may be dead, abort to
161
+ // reclaim it" is dishonest there on two counts: leg activity may be irrelevant to why it
162
+ // failed, and status:'error' is already TERMINAL — the process has already exited, so
163
+ // there is nothing left to abort. Give crashed its own, honest copy with no abort claim;
164
+ // it is terminal, so this same tick's terminal branch (above) immediately calls openRun(),
165
+ // which repaints the banner with the real run.error text via renderBanners() — one tick of
166
+ // blast radius. Only `stalled` (a still-running, no-activity heuristic) gets the abort
167
+ // remedy, since only a non-terminal run can plausibly still be aborted.
168
+ if (live.flags.crashed) {
169
+ R.renderBanner(A.$('banner'), 'Run reported an error — refreshing with the final result…', '');
170
+ } else if (live.flags.stalled) {
171
+ var mins = live.flags.stalledForSeconds ? Math.round(live.flags.stalledForSeconds / 60) : null;
172
+ // ⚠️ Code review round 2, finding 1: tagged 'live' (a marker class, no CSS of its own —
173
+ // see workspace.css, which styles only 'warn'/'info') so the clear arm below can find it.
174
+ // `stalled` is recomputed per read and simply omitted once activity resumes
175
+ // (src/mcp-council-awareness.js), so this banner is expected to be transient — but the OLD
176
+ // clear arm only ever matched the 'info' class, so a `''`-kind stalled banner painted here
177
+ // could never be cleared again: `renderBanner(el, text, '')` sets `className = 'banner '`,
178
+ // permanently failing `classList.contains('info')` from that point on, for the rest of the
179
+ // session, even once `flags.stalled` goes back to false on the very next tick.
180
+ R.renderBanner(A.$('banner'),
181
+ 'No leg activity' + (mins ? ' for ' + mins + 'm' : '') + ' — the run may be dead. Abort to reclaim it; everything on disk stays browsable.',
182
+ 'live');
183
+ A.$('abort-btn').hidden = false; // the remedy ships beside the diagnosis (Task 16 wires it)
184
+ } else if (A.$('banner').classList.contains('live')) {
185
+ // Restores whatever the DURABLE banner actually says (nothing, a schemaVersion mismatch,
186
+ // run.error, …) instead of just blanking it — a live-layer banner (info-unavailable or
187
+ // stalled) must hand back to the true state once neither condition holds, not stomp a
188
+ // real warning that predates it.
189
+ A.renderBanners();
190
+ }
191
+ }
192
+
193
+ // ---- abort (Task 16; spec §8) -----------------------------------------
194
+ // Confirm-gated: the dialog only ever shows/hides; workspace:abort-run is invoked
195
+ // solely from the confirm button below, never from opening the dialog itself.
196
+ function openAbortDialog() {
197
+ var A = window.AmicusApp;
198
+ A.$('dialog-abort').hidden = false;
199
+ A.$('dialog-abort-cancel').focus(); // Esc/Enter both land on Cancel — the safe default
200
+ }
201
+
202
+ $('abort-btn').addEventListener('click', openAbortDialog);
203
+ $('dialog-abort-cancel').addEventListener('click', function () {
204
+ $('dialog-abort').hidden = true;
205
+ });
206
+ $('dialog-abort-confirm').addEventListener('click', function () {
207
+ var A = window.AmicusApp;
208
+ var btn = $('dialog-abort-confirm');
209
+ if (btn.disabled) { return; } // a rapid second click while the first is in flight: no-op
210
+ btn.disabled = true;
211
+ A.invoke('workspace:abort-run', A.state.runId).then(function (res) {
212
+ btn.disabled = false;
213
+ $('dialog-abort').hidden = true;
214
+ if (!res.ok) {
215
+ window.AmicusRender.renderBanner(A.$('banner'), 'Abort failed: ' + (res.error || res.detail || 'unknown'), '');
216
+ return;
217
+ }
218
+ stopLiveLoop();
219
+ A.openRun(A.state.runId); // re-read: status flips to aborted, grey chip, no live poll
220
+ });
221
+ });
222
+
223
+ // ⚠️ DE-ROT (F42): these three re-enter startLiveLoop on every focus/blur/visibility flip.
224
+ // That is only safe because startLiveLoop() calls stopLiveLoop() first, which bumps
225
+ // state.liveEpoch — any tick already awaiting invoke() sees the mismatch and drops instead of
226
+ // rescheduling itself. Without the epoch bump each flip forks a second poll chain that no stop
227
+ // can reach. Registered once at this file's load time (verbs.js loads before workspace-app.js,
228
+ // so window.AmicusApp does not exist yet — the callback bodies read it at call time via
229
+ // startLiveLoop/stopLiveLoop, never here).
230
+ document.addEventListener('visibilitychange', function () {
231
+ var A = window.AmicusApp;
232
+ if (A.state.liveTimer) { startLiveLoop(); }
233
+ });
234
+ window.addEventListener('blur', function () {
235
+ var A = window.AmicusApp;
236
+ if (A.state.liveTimer) { startLiveLoop(); }
237
+ });
238
+ window.addEventListener('focus', function () {
239
+ var A = window.AmicusApp;
240
+ if (A.state.liveTimer) { startLiveLoop(); }
241
+ });
242
+
243
+ window.AmicusVerbs = {
244
+ doFold: doFold, startLiveLoop: startLiveLoop, stopLiveLoop: stopLiveLoop, applyLive: applyLive,
245
+ openAbortDialog: openAbortDialog,
246
+ };
247
+ })();