thevoidforge 21.0.0 → 21.0.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.
@@ -0,0 +1,880 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ const FAST_POLL_MS = 5000; // 5s — live feed (context, cost)
5
+ const SLOW_POLL_MS = 60000; // 60s — system status (version, deploy, experiments)
6
+
7
+ // ── Data fetchers ────────────────────────────────
8
+
9
+ async function fetchJSON(url) {
10
+ try {
11
+ const res = await fetch(url, { headers: { 'X-VoidForge-Request': '1' } });
12
+ if (!res.ok) return null;
13
+ return await res.json();
14
+ } catch { return null; }
15
+ }
16
+
17
+ // ── Campaign Timeline ────────────────────────────
18
+
19
+ function renderTimeline(campaignData) {
20
+ const container = document.getElementById('campaign-timeline');
21
+ if (!campaignData || !campaignData.missions) {
22
+ container.innerHTML = '<span style="font-size:12px;color:var(--text-dim)">No campaign active</span>';
23
+ return;
24
+ }
25
+ container.innerHTML = '';
26
+ for (const mission of campaignData.missions) {
27
+ const el = document.createElement('div');
28
+ el.className = 'timeline-item';
29
+ el.textContent = mission.number;
30
+ el.title = mission.name + ' — ' + mission.status;
31
+ switch (mission.status) {
32
+ case 'COMPLETE': el.classList.add('timeline-complete'); break;
33
+ case 'ACTIVE': el.classList.add('timeline-active'); break;
34
+ case 'BLOCKED': el.classList.add('timeline-blocked'); break;
35
+ default: el.classList.add('timeline-pending');
36
+ }
37
+ container.appendChild(el);
38
+ }
39
+ }
40
+
41
+ // ── Phase Pipeline ───────────────────────────────
42
+
43
+ function renderPipeline(phaseData) {
44
+ const container = document.getElementById('phase-pipeline');
45
+ if (!phaseData || !phaseData.phases) {
46
+ container.innerHTML = '<div class="pipeline-phase"><span class="pipeline-dot pending"></span><span class="pipeline-label">No active build</span></div>';
47
+ return;
48
+ }
49
+ container.innerHTML = '';
50
+ for (const phase of phaseData.phases) {
51
+ const el = document.createElement('div');
52
+ el.className = 'pipeline-phase';
53
+ const dot = document.createElement('span');
54
+ dot.className = 'pipeline-dot ' + (phase.status || 'pending');
55
+ const label = document.createElement('span');
56
+ label.className = 'pipeline-label';
57
+ label.textContent = phase.name;
58
+ el.appendChild(dot);
59
+ el.appendChild(label);
60
+ container.appendChild(el);
61
+ }
62
+ }
63
+
64
+ // ── Finding Scoreboard ───────────────────────────
65
+
66
+ function renderScoreboard(findings) {
67
+ document.getElementById('score-critical').textContent = (findings && findings.critical) || 0;
68
+ document.getElementById('score-high').textContent = (findings && findings.high) || 0;
69
+ document.getElementById('score-medium').textContent = (findings && findings.medium) || 0;
70
+ document.getElementById('score-low').textContent = (findings && findings.low) || 0;
71
+ }
72
+
73
+ // ── Context Gauge ────────────────────────────────
74
+
75
+ function renderGauge(usage) {
76
+ const fill = document.getElementById('gauge-fill');
77
+ const text = document.getElementById('gauge-text');
78
+ const gauge = document.getElementById('context-gauge');
79
+ const emptyHint = document.getElementById('context-empty');
80
+ const modelDisplay = document.getElementById('context-model');
81
+ const headerCtx = document.getElementById('header-context');
82
+ if (!usage) {
83
+ text.textContent = '\u2014%';
84
+ fill.style.strokeDashoffset = 88;
85
+ if (headerCtx) headerCtx.textContent = '\u2014%';
86
+ if (gauge) gauge.removeAttribute('aria-valuenow');
87
+ if (emptyHint) emptyHint.style.display = '';
88
+ if (modelDisplay) modelDisplay.textContent = '';
89
+ return;
90
+ }
91
+ if (emptyHint) emptyHint.style.display = 'none';
92
+ const pct = Math.round(usage.percent);
93
+ const offset = 88 - (88 * pct / 100);
94
+ fill.style.strokeDashoffset = offset;
95
+ if (pct < 50) fill.style.stroke = 'var(--success)';
96
+ else if (pct < 70) fill.style.stroke = 'var(--warning)';
97
+ else fill.style.stroke = 'var(--error)';
98
+ text.textContent = pct + '%';
99
+ if (gauge) gauge.setAttribute('aria-valuenow', pct);
100
+ // Compact header indicator (always visible — Gauntlet UX-005)
101
+ if (headerCtx) {
102
+ headerCtx.textContent = pct + '%';
103
+ headerCtx.style.color = pct < 50 ? 'var(--success)' : pct < 70 ? 'var(--warning)' : 'var(--error)';
104
+ headerCtx.style.borderColor = headerCtx.style.color;
105
+ }
106
+ if (modelDisplay && usage.model) modelDisplay.textContent = usage.model;
107
+ // Update cost display from same data source
108
+ var costEl = document.getElementById('cost-display');
109
+ var costEmpty = document.getElementById('cost-empty');
110
+ if (costEl && usage.cost != null) {
111
+ costEl.textContent = '$' + usage.cost.toFixed(4);
112
+ if (costEmpty) costEmpty.style.display = 'none';
113
+ }
114
+ }
115
+
116
+ // ── Version & Branch ─────────────────────────────
117
+
118
+ function renderVersion(versionData) {
119
+ document.getElementById('version-badge').textContent = versionData ? ('v' + versionData.version) : '—';
120
+ document.getElementById('version-display').textContent = versionData ? ('VoidForge v' + versionData.version) : 'VoidForge';
121
+ document.getElementById('branch-status').textContent = versionData ? versionData.branch : '—';
122
+ }
123
+
124
+ // ── Deploy Status ────────────────────────────────
125
+
126
+ function renderDeploy(deployData) {
127
+ const container = document.getElementById('deploy-status');
128
+ if (!deployData || !deployData.url) {
129
+ container.innerHTML = '<span class="deploy-dot unknown"></span><span>No deploy data</span>';
130
+ return;
131
+ }
132
+ const dotClass = deployData.healthy ? 'live' : 'down';
133
+ container.innerHTML = '';
134
+ const dot = document.createElement('span');
135
+ dot.className = 'deploy-dot ' + dotClass;
136
+ const label = document.createElement('span');
137
+ label.textContent = deployData.url;
138
+ container.appendChild(dot);
139
+ container.appendChild(label);
140
+ }
141
+
142
+ // ── Agent Activity Ticker ────────────────────────
143
+
144
+ const tickerMessages = [];
145
+ const MAX_TICKER = 10;
146
+
147
+ function addTickerMessage(agent, action) {
148
+ tickerMessages.unshift({ agent, action, time: Date.now() });
149
+ if (tickerMessages.length > MAX_TICKER) tickerMessages.pop();
150
+ renderTicker();
151
+ }
152
+
153
+ function renderTicker() {
154
+ // Update both: footer ticker (scrolling) and Tier 1 panel (detailed)
155
+ const footer = document.getElementById('agent-ticker');
156
+ const panel = document.getElementById('agent-ticker-panel');
157
+ if (tickerMessages.length === 0) {
158
+ if (footer) footer.innerHTML = '<span class="ticker-item"><span class="ticker-agent">Sisko</span> standing by...</span>';
159
+ return;
160
+ }
161
+ var html = tickerMessages.map(m =>
162
+ `<span class="ticker-item"><span class="ticker-agent">${escapeHtml(m.agent)}</span> ${escapeHtml(m.action)}</span>`
163
+ ).join('');
164
+ if (footer) footer.innerHTML = html;
165
+ if (panel) panel.innerHTML = tickerMessages.map(m =>
166
+ `<div style="margin-bottom:4px;"><span class="ticker-agent">${escapeHtml(m.agent)}</span> <span style="color:var(--text-dim)">${escapeHtml(m.action)}</span></div>`
167
+ ).join('');
168
+ }
169
+
170
+ function escapeHtml(str) {
171
+ const div = document.createElement('div');
172
+ div.appendChild(document.createTextNode(str));
173
+ return div.innerHTML;
174
+ }
175
+
176
+ // ── PRD Coverage ─────────────────────────────────
177
+
178
+ function renderPrdCoverage(coverage) {
179
+ const container = document.getElementById('prd-coverage');
180
+ if (!coverage || !coverage.sections) {
181
+ container.textContent = 'No campaign active';
182
+ return;
183
+ }
184
+ const complete = coverage.sections.filter(s => s.status === 'COMPLETE').length;
185
+ const total = coverage.sections.length;
186
+ const pct = total > 0 ? Math.round(complete / total * 100) : 0;
187
+ container.innerHTML = `<div style="margin-bottom:6px">${complete}/${total} sections (${pct}%)</div>` +
188
+ `<div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden">` +
189
+ `<div style="height:100%;width:${pct}%;background:var(--success);border-radius:3px;transition:width 0.5s"></div></div>`;
190
+ }
191
+
192
+ // ── Test Suite ───────────────────────────────────
193
+
194
+ function renderTests(testData) {
195
+ const container = document.getElementById('test-status');
196
+ if (!testData) { container.textContent = 'No test data'; return; }
197
+ container.innerHTML =
198
+ `<span style="color:var(--success)">${testData.pass || 0} pass</span> · ` +
199
+ `<span style="color:var(--error)">${testData.fail || 0} fail</span> · ` +
200
+ `<span style="color:var(--text-dim)">${testData.skip || 0} skip</span>`;
201
+ }
202
+
203
+ // ── Experiment Dashboard ──────────────────────────
204
+
205
+ function renderExperiments(data) {
206
+ const container = document.getElementById('experiment-dashboard');
207
+ if (!data || !data.experiments || data.experiments.length === 0) {
208
+ container.textContent = 'No experiments';
209
+ return;
210
+ }
211
+ var complete = data.experiments.filter(function(e) { return e.status === 'complete'; }).length;
212
+ var running = data.experiments.filter(function(e) { return e.status === 'running'; }).length;
213
+ var planned = data.experiments.filter(function(e) { return e.status === 'planned'; }).length;
214
+ container.innerHTML =
215
+ '<span style="color:var(--success)">' + complete + ' complete</span> · ' +
216
+ '<span style="color:var(--warning)">' + running + ' running</span> · ' +
217
+ '<span style="color:var(--text-dim)">' + planned + ' planned</span>';
218
+ }
219
+
220
+ // ── Growth Tab: KPI Rendering ──────────────────────
221
+
222
+ function formatCents(cents) {
223
+ if (cents == null) return '$0.00';
224
+ return '$' + (Math.abs(cents) / 100).toFixed(2);
225
+ }
226
+
227
+ function formatRoas(roas) {
228
+ if (roas == null || roas === 0) return '0.0x';
229
+ return roas.toFixed(1) + 'x';
230
+ }
231
+
232
+ function renderGrowthTab(treasury) {
233
+ var emptyState = document.getElementById('growth-empty-state');
234
+ var kpiRow = document.getElementById('growth-kpi-row');
235
+ var systemStatus = document.getElementById('growth-system-status');
236
+ var roasPanel = document.getElementById('growth-roas-panel');
237
+ var trafficPanel = document.getElementById('growth-traffic-panel');
238
+ var funnelPanel = document.getElementById('growth-funnel-panel');
239
+
240
+ if (!cultivationInstalled) {
241
+ if (emptyState) emptyState.style.display = '';
242
+ if (kpiRow) kpiRow.style.display = 'none';
243
+ if (systemStatus) systemStatus.innerHTML = 'Cultivation: not installed \u2014 run <code>/cultivation install</code> to begin';
244
+ return;
245
+ }
246
+
247
+ if (systemStatus) systemStatus.innerHTML = 'Cultivation: <span style="color:var(--fin-healthy);">active</span>';
248
+
249
+ var hasData = treasury && (treasury.revenue > 0 || treasury.spend > 0);
250
+ if (!hasData) {
251
+ if (emptyState) { emptyState.style.display = ''; emptyState.innerHTML = '<div>Cultivation installed but no financial data yet.</div><div style="margin-top:8px;">Run <code>/grow</code> to start your first growth campaign.</div>'; }
252
+ if (kpiRow) kpiRow.style.display = 'none';
253
+ return;
254
+ }
255
+
256
+ if (emptyState) emptyState.style.display = 'none';
257
+ if (kpiRow) kpiRow.style.display = '';
258
+ if (roasPanel) roasPanel.style.display = '';
259
+ if (trafficPanel) trafficPanel.style.display = '';
260
+ if (funnelPanel) funnelPanel.style.display = '';
261
+
262
+ var revenueEl = document.getElementById('kpi-revenue');
263
+ var spendEl = document.getElementById('kpi-spend');
264
+ var netEl = document.getElementById('kpi-net');
265
+ var roasEl = document.getElementById('kpi-roas');
266
+
267
+ if (revenueEl) revenueEl.textContent = formatCents(treasury.revenue);
268
+ if (spendEl) spendEl.textContent = formatCents(treasury.spend);
269
+ if (netEl) {
270
+ netEl.textContent = formatCents(treasury.net);
271
+ netEl.style.color = treasury.net >= 0 ? 'var(--fin-positive)' : 'var(--fin-negative)';
272
+ }
273
+ if (roasEl) roasEl.textContent = formatRoas(treasury.roas);
274
+
275
+ // ── Stablecoin funding indicators (v19.0) ──
276
+ var fundingRow = document.getElementById('growth-funding-row');
277
+ if (fundingRow) {
278
+ var hasFunding = treasury.fundingState != null || treasury.runwayDays != null;
279
+ fundingRow.style.display = hasFunding ? '' : 'none';
280
+
281
+ // Runway badge
282
+ var runwayBadge = document.getElementById('growth-runway-badge');
283
+ if (runwayBadge && treasury.runwayDays != null) {
284
+ runwayBadge.textContent = treasury.runwayDays + ' days runway';
285
+ runwayBadge.style.color = treasury.runwayDays > 14 ? 'var(--success)'
286
+ : treasury.runwayDays >= 7 ? 'var(--warning)' : 'var(--error)';
287
+ } else if (runwayBadge) {
288
+ runwayBadge.textContent = '\u2014';
289
+ runwayBadge.style.color = 'var(--text-dim)';
290
+ }
291
+
292
+ // Funding risk indicator
293
+ var fundingRisk = document.getElementById('growth-funding-risk');
294
+ if (fundingRisk && treasury.fundingState != null) {
295
+ fundingRisk.textContent = 'Funding: ' + treasury.fundingState;
296
+ fundingRisk.style.color = treasury.fundingState === 'healthy' ? 'var(--success)'
297
+ : treasury.fundingState === 'degraded' ? 'var(--warning)' : 'var(--error)';
298
+ } else if (fundingRisk) {
299
+ fundingRisk.textContent = '';
300
+ }
301
+
302
+ // Next treasury event
303
+ var nextEvent = document.getElementById('growth-next-event');
304
+ if (nextEvent) {
305
+ nextEvent.textContent = treasury.nextTreasuryEvent ? 'Next: ' + treasury.nextTreasuryEvent : '';
306
+ }
307
+ }
308
+ }
309
+
310
+ // ── Campaigns Tab: Table Rendering ────────────────
311
+
312
+ function renderCampaignsTab(campaigns) {
313
+ var emptyState = document.getElementById('campaigns-empty-state');
314
+ var tablePanel = document.getElementById('campaigns-table-panel');
315
+ var abPanel = document.getElementById('campaigns-ab-panel');
316
+ var recsPanel = document.getElementById('campaigns-recommendations-panel');
317
+
318
+ if (!cultivationInstalled) {
319
+ if (emptyState) { emptyState.style.display = ''; emptyState.innerHTML = '<div>No ad campaigns yet.</div><div style="margin-top:8px;">Run <code>/grow --setup</code> to configure ad platforms.</div>'; }
320
+ if (tablePanel) tablePanel.style.display = 'none';
321
+ return;
322
+ }
323
+
324
+ if (!campaigns || campaigns.length === 0) {
325
+ if (emptyState) { emptyState.style.display = ''; emptyState.innerHTML = '<div>No campaigns yet.</div><div style="margin-top:8px;">Run <code>/grow --setup</code> to configure ad platforms.</div>'; }
326
+ if (tablePanel) tablePanel.style.display = 'none';
327
+ return;
328
+ }
329
+
330
+ if (emptyState) emptyState.style.display = 'none';
331
+ if (tablePanel) tablePanel.style.display = '';
332
+ if (abPanel) abPanel.style.display = '';
333
+ if (recsPanel) recsPanel.style.display = '';
334
+
335
+ var tbody = document.getElementById('campaigns-tbody');
336
+ if (!tbody) return;
337
+ tbody.innerHTML = '';
338
+
339
+ for (var i = 0; i < campaigns.length; i++) {
340
+ var c = campaigns[i];
341
+ var tr = document.createElement('tr');
342
+ tr.style.borderBottom = '1px solid var(--border)';
343
+
344
+ var statusColor = c.status === 'active' ? 'var(--fin-healthy)'
345
+ : c.status === 'paused' ? 'var(--fin-warning)'
346
+ : 'var(--fin-inactive)';
347
+
348
+ // Billing capability badge (v19.0)
349
+ var capState = c.capabilityState || c.billingCapability || null;
350
+ var capBadge = '';
351
+ if (capState) {
352
+ var capColor = capState === 'FULLY_FUNDABLE' ? 'var(--success)'
353
+ : capState === 'MONITORED_ONLY' ? 'var(--warning)' : 'var(--error)';
354
+ capBadge = '<span style="font-size:10px;padding:1px 6px;border-radius:3px;background:' + capColor + '20;color:' + capColor + ';">' + escapeHtml(capState) + '</span>';
355
+ }
356
+
357
+ // Warning if campaign healthy but billing rail degraded
358
+ var billingWarn = '';
359
+ if (c.status === 'active' && capState && capState !== 'FULLY_FUNDABLE') {
360
+ billingWarn = ' <span style="color:var(--warning);font-size:10px;" title="Billing rail is not fully fundable">\u26a0</span>';
361
+ }
362
+
363
+ tr.innerHTML =
364
+ '<td style="padding:6px 8px;">' + escapeHtml(c.platform || '\u2014') + ' ' + capBadge + '</td>' +
365
+ '<td style="padding:6px 8px;">' + escapeHtml(c.name || c.id || '\u2014') + '</td>' +
366
+ '<td style="padding:6px 8px;">' + formatCents(c.spendCents || c.spend || 0) + '</td>' +
367
+ '<td style="padding:6px 8px;">' + (c.conversions != null ? c.conversions : '\u2014') + '</td>' +
368
+ '<td style="padding:6px 8px;">' + formatRoas(c.roas) + '</td>' +
369
+ '<td style="padding:6px 8px;color:' + statusColor + ';">' + escapeHtml(c.status || 'unknown') + billingWarn + '</td>';
370
+ tbody.appendChild(tr);
371
+ }
372
+ }
373
+
374
+ // ── Treasury Tab: Financial Summary ───────────────
375
+
376
+ function renderTreasuryTab(treasury, heartbeat) {
377
+ var emptyState = document.getElementById('treasury-empty-state');
378
+ var kpiRow = document.getElementById('treasury-kpi-row');
379
+ var budgetPanel = document.getElementById('treasury-budget-panel');
380
+ var connectionsPanel = document.getElementById('treasury-connections-panel');
381
+ var reconciliationPanel = document.getElementById('treasury-reconciliation-panel');
382
+
383
+ if (!cultivationInstalled) {
384
+ if (emptyState) emptyState.style.display = '';
385
+ if (kpiRow) kpiRow.style.display = 'none';
386
+ if (budgetPanel) budgetPanel.style.display = 'none';
387
+ return;
388
+ }
389
+
390
+ if (emptyState) emptyState.style.display = 'none';
391
+ if (kpiRow) kpiRow.style.display = '';
392
+ if (budgetPanel) budgetPanel.style.display = '';
393
+ if (connectionsPanel) connectionsPanel.style.display = '';
394
+ if (reconciliationPanel) reconciliationPanel.style.display = '';
395
+
396
+ // ── Stablecoin funding rail section (v19.0) ──
397
+ var fundingPanel = document.getElementById('treasury-funding-panel');
398
+ if (fundingPanel) {
399
+ var hasFundingData = treasury.stablecoinBalance != null || treasury.bankAvailable != null
400
+ || treasury.pendingOfframps > 0 || treasury.unsettledInvoices > 0;
401
+ fundingPanel.style.display = hasFundingData ? '' : 'none';
402
+
403
+ if (hasFundingData) {
404
+ var fundingHtml = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">';
405
+
406
+ // Stablecoin balance card
407
+ if (treasury.stablecoinBalance != null) {
408
+ fundingHtml += '<div style="padding:10px;background:var(--bg);border-radius:4px;">' +
409
+ '<div style="font-size:10px;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px;">USDC Balance</div>' +
410
+ '<div style="font-size:18px;font-weight:700;">' + formatCents(treasury.stablecoinBalance) + ' <span style="font-size:11px;color:var(--text-dim);">USDC</span></div></div>';
411
+ }
412
+
413
+ // Pending off-ramps card
414
+ if (treasury.pendingOfframps > 0) {
415
+ fundingHtml += '<div style="padding:10px;background:var(--bg);border-radius:4px;">' +
416
+ '<div style="font-size:10px;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px;">Pending Transfers</div>' +
417
+ '<div style="font-size:18px;font-weight:700;color:var(--warning);">' + treasury.pendingOfframps + ' <span style="font-size:11px;">off-ramp' + (treasury.pendingOfframps !== 1 ? 's' : '') + '</span></div></div>';
418
+ }
419
+
420
+ // Bank balance card
421
+ if (treasury.bankAvailable != null) {
422
+ var reservedText = treasury.bankReserved != null && treasury.bankReserved > 0
423
+ ? ' / ' + formatCents(treasury.bankReserved) + ' reserved'
424
+ : '';
425
+ fundingHtml += '<div style="padding:10px;background:var(--bg);border-radius:4px;">' +
426
+ '<div style="font-size:10px;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px;">Bank Balance</div>' +
427
+ '<div style="font-size:18px;font-weight:700;">' + formatCents(treasury.bankAvailable) + ' <span style="font-size:11px;color:var(--text-dim);">available' + escapeHtml(reservedText) + '</span></div></div>';
428
+ }
429
+
430
+ // Unsettled invoices card
431
+ if (treasury.unsettledInvoices > 0) {
432
+ fundingHtml += '<div style="padding:10px;background:var(--bg);border-radius:4px;">' +
433
+ '<div style="font-size:10px;text-transform:uppercase;color:var(--text-dim);margin-bottom:4px;">Unsettled Invoices</div>' +
434
+ '<div style="font-size:18px;font-weight:700;color:var(--warning);">' + treasury.unsettledInvoices + ' <span style="font-size:11px;">invoice' + (treasury.unsettledInvoices !== 1 ? 's' : '') + '</span></div></div>';
435
+ }
436
+
437
+ fundingHtml += '</div>';
438
+
439
+ // Status badges row
440
+ fundingHtml += '<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">';
441
+
442
+ // Funding state badge
443
+ if (treasury.fundingState != null) {
444
+ var fsColor = treasury.fundingState === 'healthy' ? 'var(--success)'
445
+ : treasury.fundingState === 'degraded' ? 'var(--warning)' : 'var(--error)';
446
+ fundingHtml += '<span style="font-size:11px;padding:3px 10px;border-radius:4px;background:' + fsColor + '20;color:' + fsColor + ';font-weight:600;">' + escapeHtml(treasury.fundingState.toUpperCase()) + '</span>';
447
+ }
448
+
449
+ // Reconciliation status badge
450
+ if (treasury.reconciliationStatus != null) {
451
+ var reconColor = treasury.reconciliationStatus === 'matched' ? 'var(--success)' : 'var(--error)';
452
+ fundingHtml += '<span style="font-size:11px;padding:3px 10px;border-radius:4px;background:' + reconColor + '20;color:' + reconColor + ';font-weight:600;">Recon: ' + escapeHtml(treasury.reconciliationStatus) + '</span>';
453
+ }
454
+
455
+ fundingHtml += '</div>';
456
+
457
+ var fundingContent = document.getElementById('treasury-funding-content');
458
+ if (fundingContent) fundingContent.innerHTML = fundingHtml;
459
+ }
460
+ }
461
+
462
+ // KPI row
463
+ var revEl = document.getElementById('treasury-revenue');
464
+ var spendEl = document.getElementById('treasury-spend');
465
+ var netEl = document.getElementById('treasury-net');
466
+ var roasEl = document.getElementById('treasury-roas');
467
+
468
+ if (revEl) revEl.textContent = formatCents(treasury.revenue);
469
+ if (spendEl) spendEl.textContent = formatCents(treasury.spend);
470
+ if (netEl) {
471
+ netEl.textContent = formatCents(treasury.net);
472
+ netEl.style.color = treasury.net >= 0 ? 'var(--fin-positive)' : 'var(--fin-negative)';
473
+ }
474
+ if (roasEl) roasEl.textContent = formatRoas(treasury.roas);
475
+
476
+ // Budget bar
477
+ var budgetBar = document.getElementById('treasury-budget-bar');
478
+ var budgetUsed = document.getElementById('treasury-budget-used');
479
+ var budgetTotal = document.getElementById('treasury-budget-total');
480
+ var totalBudget = treasury.spend + (treasury.budgetRemaining || 0);
481
+ var pct = totalBudget > 0 ? Math.min(100, Math.round(treasury.spend / totalBudget * 100)) : 0;
482
+
483
+ if (budgetBar) {
484
+ budgetBar.style.width = pct + '%';
485
+ budgetBar.setAttribute('aria-valuenow', pct);
486
+ budgetBar.setAttribute('aria-valuetext', pct + '% of budget used');
487
+ budgetBar.style.background = pct > 90 ? 'var(--fin-negative)' : pct > 75 ? 'var(--fin-warning)' : 'var(--fin-positive)';
488
+ }
489
+ if (budgetUsed) budgetUsed.textContent = formatCents(treasury.spend) + ' used';
490
+ if (budgetTotal) budgetTotal.textContent = totalBudget > 0 ? formatCents(totalBudget) + ' total' : 'No budget set';
491
+
492
+ // Connections — show active platforms from heartbeat
493
+ var connectionsEl = document.getElementById('treasury-connections');
494
+ if (connectionsEl && heartbeat) {
495
+ var platforms = heartbeat.activePlatforms || [];
496
+ if (platforms.length === 0) {
497
+ connectionsEl.innerHTML = '<span style="color:var(--text-dim);">No platforms connected</span>';
498
+ } else {
499
+ connectionsEl.innerHTML = platforms.map(function (p) {
500
+ return '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">' +
501
+ '<span class="deploy-dot live"></span>' +
502
+ '<span>' + escapeHtml(p) + '</span></div>';
503
+ }).join('');
504
+ }
505
+ }
506
+
507
+ // Vault/daemon state
508
+ var reconEl = document.getElementById('treasury-reconciliation');
509
+ if (reconEl && heartbeat) {
510
+ var vaultState = heartbeat.state === 'degraded' ? 'locked' : 'unlocked';
511
+ var vaultColor = vaultState === 'unlocked' ? 'var(--fin-healthy)' : 'var(--fin-warning)';
512
+ reconEl.innerHTML =
513
+ '<div style="margin-bottom:4px;"><span style="color:var(--text-dim);">Vault:</span> <span style="color:' + vaultColor + ';">' + vaultState + '</span></div>' +
514
+ '<div><span style="color:var(--text-dim);">Daemon:</span> <span style="color:var(--fin-healthy);">' + escapeHtml(heartbeat.state || 'unknown') + '</span></div>';
515
+ } else if (reconEl) {
516
+ reconEl.innerHTML = '<span style="color:var(--text-dim);">Daemon not running</span>';
517
+ }
518
+ }
519
+
520
+ // ── Heartbeat Tab: Daemon Status ──────────────────
521
+
522
+ function formatUptime(startedAt) {
523
+ if (!startedAt) return '\u2014';
524
+ var diff = Date.now() - new Date(startedAt).getTime();
525
+ if (diff < 0) return '\u2014';
526
+ var hours = Math.floor(diff / 3600000);
527
+ var minutes = Math.floor((diff % 3600000) / 60000);
528
+ if (hours > 0) return hours + 'h ' + minutes + 'm';
529
+ return minutes + 'm';
530
+ }
531
+
532
+ function formatRelativeTime(ts) {
533
+ if (!ts) return '\u2014';
534
+ var diff = Date.now() - new Date(ts).getTime();
535
+ if (diff < 0) return 'just now';
536
+ if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
537
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
538
+ return Math.floor(diff / 3600000) + 'h ago';
539
+ }
540
+
541
+ function renderHeartbeatTab(heartbeat) {
542
+ var emptyState = document.getElementById('heartbeat-empty-state');
543
+ var statusPanel = document.getElementById('heartbeat-status-panel');
544
+ var tokensPanel = document.getElementById('heartbeat-tokens-panel');
545
+ var jobsPanel = document.getElementById('heartbeat-jobs-panel');
546
+ var alertsPanel = document.getElementById('heartbeat-alerts-panel');
547
+ var fundingOpsPanel = document.getElementById('heartbeat-funding-ops-panel');
548
+
549
+ if (!cultivationInstalled || !heartbeat) {
550
+ if (emptyState) emptyState.style.display = '';
551
+ if (statusPanel) statusPanel.style.display = 'none';
552
+ if (tokensPanel) tokensPanel.style.display = 'none';
553
+ if (jobsPanel) jobsPanel.style.display = 'none';
554
+ if (alertsPanel) alertsPanel.style.display = 'none';
555
+ if (fundingOpsPanel) fundingOpsPanel.style.display = 'none';
556
+ return;
557
+ }
558
+
559
+ if (emptyState) emptyState.style.display = 'none';
560
+ if (statusPanel) statusPanel.style.display = '';
561
+
562
+ // State
563
+ var stateEl = document.getElementById('hb-state');
564
+ if (stateEl) {
565
+ stateEl.textContent = heartbeat.state || 'unknown';
566
+ stateEl.style.color = heartbeat.state === 'healthy' ? 'var(--fin-healthy)'
567
+ : heartbeat.state === 'degraded' ? 'var(--fin-warning)'
568
+ : 'var(--fin-error)';
569
+ }
570
+
571
+ // PID
572
+ var pidEl = document.getElementById('hb-pid');
573
+ if (pidEl) pidEl.textContent = heartbeat.pid || '\u2014';
574
+
575
+ // Uptime
576
+ var uptimeEl = document.getElementById('hb-uptime');
577
+ if (uptimeEl) uptimeEl.textContent = formatUptime(heartbeat.startedAt);
578
+
579
+ // Last heartbeat
580
+ var lastBeatEl = document.getElementById('hb-last-beat');
581
+ if (lastBeatEl) lastBeatEl.textContent = formatRelativeTime(heartbeat.lastHeartbeat);
582
+
583
+ // Token health
584
+ var tokenHealth = heartbeat.tokenHealth || {};
585
+ var tokenList = document.getElementById('hb-token-list');
586
+ var platformKeys = Object.keys(tokenHealth);
587
+ if (tokensPanel && platformKeys.length > 0) {
588
+ tokensPanel.style.display = '';
589
+ if (tokenList) {
590
+ tokenList.innerHTML = platformKeys.map(function (p) {
591
+ var info = tokenHealth[p];
592
+ var statusColor = info.status === 'healthy' ? 'var(--fin-healthy)'
593
+ : info.status === 'requires_reauth' ? 'var(--fin-error)'
594
+ : 'var(--fin-warning)';
595
+ var expiry = info.expiresAt ? ' \u00b7 expires ' + formatRelativeTime(info.expiresAt) : '';
596
+ return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">' +
597
+ '<span class="deploy-dot" style="background:' + statusColor + ';"></span>' +
598
+ '<span>' + escapeHtml(p) + '</span>' +
599
+ '<span style="color:' + statusColor + ';font-size:11px;">' + escapeHtml(info.status || 'unknown') + expiry + '</span></div>';
600
+ }).join('');
601
+ }
602
+ } else if (tokensPanel) {
603
+ tokensPanel.style.display = '';
604
+ if (tokenList) tokenList.innerHTML = '<span style="color:var(--text-dim);">No platform tokens configured</span>';
605
+ }
606
+
607
+ // Scheduled jobs (show next expected jobs based on daemon state)
608
+ if (jobsPanel) {
609
+ jobsPanel.style.display = '';
610
+ var jobsList = document.getElementById('hb-jobs-list');
611
+ if (jobsList) {
612
+ var activePlatforms = heartbeat.activePlatforms || [];
613
+ var activeCampaigns = heartbeat.activeCampaigns || 0;
614
+ jobsList.innerHTML =
615
+ '<div style="margin-bottom:4px;"><span style="color:var(--text-dim);">Active platforms:</span> ' + (activePlatforms.length > 0 ? escapeHtml(activePlatforms.join(', ')) : 'none') + '</div>' +
616
+ '<div style="margin-bottom:4px;"><span style="color:var(--text-dim);">Active campaigns:</span> ' + activeCampaigns + '</div>' +
617
+ '<div style="margin-bottom:4px;"><span style="color:var(--text-dim);">Today spend:</span> ' + formatCents(heartbeat.todaySpend) + '</div>' +
618
+ '<div><span style="color:var(--text-dim);">Daily budget:</span> ' + (heartbeat.dailyBudget > 0 ? formatCents(heartbeat.dailyBudget) : 'not set') + '</div>';
619
+ }
620
+ }
621
+
622
+ // Alerts
623
+ var alerts = heartbeat.alerts || [];
624
+ if (alertsPanel) {
625
+ alertsPanel.style.display = '';
626
+ var alertsEl = document.getElementById('hb-alerts');
627
+ if (alertsEl) {
628
+ if (alerts.length === 0) {
629
+ alertsEl.innerHTML = '<span style="color:var(--fin-healthy);">No alerts \u2014 all systems nominal</span>';
630
+ } else {
631
+ alertsEl.innerHTML = alerts.map(function (a) {
632
+ var alertColor = a.severity === 'critical' ? 'var(--fin-error)' : a.severity === 'warning' ? 'var(--fin-warning)' : 'var(--text-dim)';
633
+ return '<div style="margin-bottom:6px;padding:6px 8px;border-left:3px solid ' + alertColor + ';background:var(--bg);border-radius:2px;">' +
634
+ '<span style="font-weight:600;color:' + alertColor + ';">' + escapeHtml(a.type || 'alert') + '</span> ' +
635
+ '<span>' + escapeHtml(a.message || '') + '</span></div>';
636
+ }).join('');
637
+ }
638
+ }
639
+ }
640
+
641
+ // ── Funding operations (v19.0 — provider sync, pending ops, reconciliation) ──
642
+ if (fundingOpsPanel) {
643
+ var hasProviderSync = heartbeat.lastProviderSync || heartbeat.pendingOpsCount != null || heartbeat.lastReconciliation;
644
+ fundingOpsPanel.style.display = hasProviderSync ? '' : 'none';
645
+
646
+ if (hasProviderSync) {
647
+ var opsContent = document.getElementById('hb-funding-ops');
648
+ if (opsContent) {
649
+ var html = '';
650
+ html += '<div style="margin-bottom:6px;"><span style="color:var(--text-dim);">Last provider sync:</span> ' +
651
+ '<span>' + formatRelativeTime(heartbeat.lastProviderSync) + '</span></div>';
652
+ html += '<div style="margin-bottom:6px;"><span style="color:var(--text-dim);">Pending operations:</span> ' +
653
+ '<span' + (heartbeat.pendingOpsCount > 0 ? ' style="color:var(--warning);"' : '') + '>' +
654
+ (heartbeat.pendingOpsCount != null ? heartbeat.pendingOpsCount : '\u2014') + '</span></div>';
655
+ html += '<div style="margin-bottom:6px;"><span style="color:var(--text-dim);">Last reconciliation:</span> ' +
656
+ '<span>' + formatRelativeTime(heartbeat.lastReconciliation) + '</span></div>';
657
+ html += '<div style="margin-top:8px;padding:6px 8px;background:var(--bg);border-radius:4px;font-size:11px;color:var(--text-dim);">' +
658
+ 'Heartbeat is the single financial writer.</div>';
659
+ opsContent.innerHTML = html;
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ // ── Tiered poll loops (v13.0 — fast for live data, slow for system status) ──
666
+
667
+ /** Fast poll (5s): live feed data that changes per-message */
668
+ async function refreshFast() {
669
+ const [context] = await Promise.all([
670
+ fetchJSON('/api/danger-room/context'),
671
+ ]);
672
+ renderGauge(context);
673
+ }
674
+
675
+ /** Campaign poll (10s): campaign state that changes per-mission */
676
+ async function refreshCampaign() {
677
+ const [campaign, build, findings] = await Promise.all([
678
+ fetchJSON('/api/danger-room/campaign'),
679
+ fetchJSON('/api/danger-room/build'),
680
+ fetchJSON('/api/danger-room/findings'),
681
+ ]);
682
+ renderTimeline(campaign);
683
+ renderPipeline(build);
684
+ renderScoreboard(findings);
685
+ renderPrdCoverage(campaign);
686
+ if (typeof window.renderProphecyGraph === 'function') {
687
+ window.renderProphecyGraph(document.getElementById('prophecy-graph'), campaign);
688
+ }
689
+ }
690
+
691
+ /** Growth poll (30s): heartbeat, treasury, and campaign data for growth tabs */
692
+ async function refreshGrowth() {
693
+ var data = await fetchJSON('/api/danger-room/heartbeat');
694
+ if (!data) return;
695
+ cultivationInstalled = !!data.cultivationInstalled;
696
+ renderGrowthTab(data.treasury);
697
+ renderCampaignsTab(data.campaigns);
698
+ renderTreasuryTab(data.treasury, data.heartbeat);
699
+ renderHeartbeatTab(data.heartbeat);
700
+ }
701
+
702
+ /** Slow poll (60s): system status that changes rarely */
703
+ async function refreshSlow() {
704
+ const [version, deploy, experiments] = await Promise.all([
705
+ fetchJSON('/api/danger-room/version'),
706
+ fetchJSON('/api/danger-room/deploy'),
707
+ fetchJSON('/api/danger-room/experiments'),
708
+ ]);
709
+ renderVersion(version);
710
+ renderDeploy(deploy);
711
+ renderExperiments(experiments);
712
+ }
713
+
714
+ /** Full refresh — all tiers at once (used on init and reconnect) */
715
+ async function refresh() {
716
+ await Promise.all([refreshFast(), refreshCampaign(), refreshGrowth(), refreshSlow()]);
717
+ }
718
+
719
+ // ── Tab Navigation (§9.20.2) ─────────────────────
720
+
721
+ var cultivationInstalled = false;
722
+
723
+ function switchTab(tabId) {
724
+ // VG-008: fall back to 'ops' for unknown tab IDs
725
+ if (!document.getElementById('tab-' + tabId)) tabId = 'ops';
726
+ var tabs = document.querySelectorAll('[role="tab"]');
727
+ var panels = document.querySelectorAll('.tab-panel');
728
+ tabs.forEach(function (t) { t.setAttribute('aria-selected', 'false'); });
729
+ panels.forEach(function (p) { p.classList.remove('active'); });
730
+ var tab = document.getElementById('tab-' + tabId);
731
+ var panel = document.getElementById('panel-' + tabId);
732
+ if (tab) tab.setAttribute('aria-selected', 'true');
733
+ if (panel) panel.classList.add('active');
734
+ location.hash = tabId === 'ops' ? '' : tabId;
735
+ }
736
+
737
+ // Arrow key navigation within tab bar
738
+ document.addEventListener('keydown', function (e) {
739
+ var tabBar = document.getElementById('tab-bar');
740
+ if (!tabBar || !tabBar.contains(document.activeElement)) return;
741
+ var tabs = Array.from(tabBar.querySelectorAll('[role="tab"]'));
742
+ var idx = tabs.indexOf(document.activeElement);
743
+ if (idx === -1) return;
744
+ if (e.key === 'ArrowRight') { tabs[(idx + 1) % tabs.length].focus(); e.preventDefault(); }
745
+ if (e.key === 'ArrowLeft') { tabs[(idx - 1 + tabs.length) % tabs.length].focus(); e.preventDefault(); }
746
+ });
747
+
748
+ function initTabs() {
749
+ // VG-009: Wire up tab clicks via addEventListener (CSP-compliant, no inline onclick)
750
+ document.querySelectorAll('[data-tab]').forEach(function (btn) {
751
+ btn.addEventListener('click', function () { switchTab(btn.dataset.tab); });
752
+ });
753
+
754
+ // Wire up freeze buttons
755
+ var freezeBtn = document.getElementById('freeze-btn');
756
+ var freezeFab = document.getElementById('freeze-fab');
757
+ if (freezeBtn) freezeBtn.addEventListener('click', handleFreeze);
758
+ if (freezeFab) freezeFab.addEventListener('click', handleFreeze);
759
+
760
+ // cultivationInstalled is set by refreshGrowth() during the initial refresh() call.
761
+ // Use that state to show/hide growth UI elements.
762
+ if (cultivationInstalled) {
763
+ document.getElementById('tab-bar').classList.add('active');
764
+ if (freezeBtn) freezeBtn.classList.add('visible');
765
+ if (freezeFab) freezeFab.classList.add('visible');
766
+ // VG-011: Default to #growth when Cultivation is installed (PRD 9.20.2)
767
+ var hash = location.hash.replace('#', '');
768
+ switchTab(hash || 'growth');
769
+ }
770
+ // Without Cultivation: no tab bar, no freeze button, flat layout preserved
771
+ }
772
+
773
+ // ── Freeze Button (§9.20.8) ─────────────────────
774
+
775
+ function handleFreeze() {
776
+ var btn = document.getElementById('freeze-btn');
777
+ var fab = document.getElementById('freeze-fab');
778
+ var isFrozen = btn.classList.contains('frozen');
779
+ if (isFrozen) {
780
+ // Unfreeze requires vault password + TOTP — show dialog
781
+ alert('Unfreeze requires vault password + 2FA. Use /treasury --unfreeze in the CLI.');
782
+ return;
783
+ }
784
+ if (!confirm('Freeze all spending across all platforms? Active campaigns will be paused.')) return;
785
+ fetch('/api/danger-room/freeze', { method: 'POST', headers: { 'X-VoidForge-Request': '1' } })
786
+ .then(function (res) { return res.json(); })
787
+ .then(function (data) {
788
+ if (data.ok) {
789
+ btn.classList.add('frozen');
790
+ btn.innerHTML = '❄ FROZEN';
791
+ btn.setAttribute('aria-pressed', 'true');
792
+ fab.classList.add('frozen');
793
+ fab.innerHTML = '❄';
794
+ addTickerMessage('Dockson', 'ALL SPENDING FROZEN');
795
+ }
796
+ })
797
+ .catch(function () { alert('Freeze failed — try /treasury --freeze in the CLI.'); });
798
+ }
799
+ // handleFreeze wired via addEventListener in initTabs()
800
+
801
+ // ── WebSocket with Reconnection Banner (§9.19.9) ──
802
+
803
+ var wsRetryDelay = 1000;
804
+ var WS_MAX_RETRY_DELAY = 30000;
805
+ var wsReconnectTimer = null;
806
+ var wsConnected = false;
807
+
808
+ function showReconnectBanner(state) {
809
+ var banner = document.getElementById('reconnect-banner');
810
+ banner.className = 'reconnect-banner';
811
+ if (state === 'reconnecting') {
812
+ banner.classList.add('reconnecting');
813
+ banner.textContent = 'Reconnecting to VoidForge server...';
814
+ } else if (state === 'failed') {
815
+ banner.classList.add('failed');
816
+ banner.innerHTML = 'Connection lost. <a href="javascript:location.reload()" style="color:white;text-decoration:underline;">Refresh page</a> or check if the VoidForge server is running.';
817
+ } else {
818
+ banner.className = 'reconnect-banner'; // hidden
819
+ }
820
+ }
821
+
822
+ function connectWebSocket() {
823
+ var wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
824
+ var ws = new WebSocket(wsProtocol + '//' + location.host + '/ws/danger-room');
825
+
826
+ ws.onopen = function () {
827
+ wsRetryDelay = 1000;
828
+ wsConnected = true;
829
+ showReconnectBanner('hidden');
830
+ // On reconnect: pull full state (§9.19.9)
831
+ refresh();
832
+ };
833
+
834
+ ws.onmessage = function (event) {
835
+ try {
836
+ var msg = JSON.parse(event.data);
837
+ if (msg.type === 'agent-activity') {
838
+ addTickerMessage(msg.agent, msg.action);
839
+ } else if (msg.type === 'finding') {
840
+ var el = document.getElementById('score-' + msg.severity);
841
+ if (el) el.textContent = parseInt(el.textContent) + 1;
842
+ } else if (msg.type === 'phase-update' || msg.type === 'growth-update') {
843
+ refresh();
844
+ }
845
+ } catch { /* ignore malformed messages */ }
846
+ };
847
+
848
+ ws.onerror = function () {};
849
+
850
+ ws.onclose = function () {
851
+ wsConnected = false;
852
+ if (wsRetryDelay <= WS_MAX_RETRY_DELAY) {
853
+ showReconnectBanner('reconnecting');
854
+ }
855
+ wsReconnectTimer = setTimeout(function () {
856
+ // After 2 minutes of failure, show permanent failure banner
857
+ if (wsRetryDelay >= WS_MAX_RETRY_DELAY * 4) {
858
+ showReconnectBanner('failed');
859
+ return;
860
+ }
861
+ connectWebSocket();
862
+ }, wsRetryDelay);
863
+ wsRetryDelay = Math.min(wsRetryDelay * 2, WS_MAX_RETRY_DELAY);
864
+ };
865
+ }
866
+
867
+ // ── Init ─────────────────────────────────────────
868
+
869
+ async function init() {
870
+ await refresh();
871
+ setInterval(refreshFast, FAST_POLL_MS);
872
+ setInterval(refreshCampaign, 10000); // 10s for campaign data
873
+ setInterval(refreshGrowth, 30000); // 30s for growth/treasury/heartbeat tabs
874
+ setInterval(refreshSlow, SLOW_POLL_MS);
875
+ connectWebSocket();
876
+ initTabs();
877
+ }
878
+
879
+ init();
880
+ })();