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