@giamme/forge 0.0.0-stage → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,687 @@
1
+ 'use strict';
2
+ (() => {
3
+ const initial = JSON.parse(document.getElementById('capture').textContent);
4
+ const app = document.querySelector('.app');
5
+ const tree = document.getElementById('tree');
6
+ const body = document.getElementById('detail-body');
7
+ const tabs = document.getElementById('tabs');
8
+ const detail = document.getElementById('detail');
9
+ const search = document.getElementById('search');
10
+ const connection = document.getElementById('connection');
11
+ const retry = document.getElementById('retry');
12
+ const pause = document.getElementById('pause-updates');
13
+ const rows = new Map();
14
+ const collapsed = new Set();
15
+ const historyOffsets = new Map();
16
+ const artifactCache = new Map();
17
+ const selectedFiles = new Map();
18
+ const requests = new Map();
19
+ let generation = 0;
20
+ let snapshot = initial;
21
+ let livePaused = false;
22
+ let followLogs = false;
23
+ let mobileNav = false;
24
+ let currentViewKey = '';
25
+ let currentViewAvailable = true;
26
+ let locationState = parseLocation();
27
+ if (!FORGE_LIVE) locationState.run = initial.config?.id || '';
28
+ const initialRun = initial.config?.id || '';
29
+ if (FORGE_LIVE && locationState.run !== initialRun && !(initial.runs && !locationState.run)) snapshot = null;
30
+
31
+ function parseLocation() {
32
+ const q = new URLSearchParams(location.search);
33
+ return {run:q.has('run') ? q.get('run') : (initial.config?.id || ''), task:q.get('task') || '',
34
+ node:q.get('node') || '', view:q.get('view') || ''};
35
+ }
36
+ function urlFor(s) {
37
+ const q = new URLSearchParams();
38
+ q.set('run', s.run || '');
39
+ if (s.task) q.set('task', s.task);
40
+ if (s.node) q.set('node', s.node);
41
+ if (s.view) q.set('view', s.view);
42
+ return '?' + q.toString();
43
+ }
44
+ function e(tag, text, className) {
45
+ const item = document.createElement(tag);
46
+ if (text !== undefined && text !== null) item.textContent = String(text);
47
+ if (className) item.className = className;
48
+ return item;
49
+ }
50
+ function setText(item, value) {
51
+ const next = String(value ?? '');
52
+ if (item.textContent !== next) item.textContent = next;
53
+ }
54
+ function tone(value) {
55
+ const word = String(value || 'unknown');
56
+ if (/^(pass|passed|complete|completed|verified|accepted)$/i.test(word)) return 'good';
57
+ if (/fail|error|stopped|rejected|invalid/i.test(word)) return 'bad';
58
+ if (/active|running|progress/i.test(word)) return 'active';
59
+ return 'warning';
60
+ }
61
+ function bound(tag, key, value, cls) {
62
+ const item = e(tag, value, cls);
63
+ item.dataset.bind = key;
64
+ return item;
65
+ }
66
+ function card(label, key, value, sub) {
67
+ const item = e('div', undefined, 'card');
68
+ item.append(e('div', label, 'card-label'), bound('div', key, value, 'card-value'));
69
+ if (['task-execution', 'task-acceptance', 'task-verification', 'task-qa', 'node-status'].includes(key))
70
+ item.querySelector('.card-value').dataset.tone = tone(value);
71
+ if (sub) item.append(e('div', sub, 'card-sub'));
72
+ return item;
73
+ }
74
+ function section(title, ...items) {
75
+ const wrap = e('section', undefined, 'section');
76
+ wrap.append(e('h3', title, 'section-title'), ...items);
77
+ return wrap;
78
+ }
79
+ function panel(title, ...items) {
80
+ const wrap = e('div', undefined, 'panel');
81
+ if (title) wrap.append(e('h3', title));
82
+ wrap.append(...items);
83
+ return wrap;
84
+ }
85
+ function field(label, value, key) {
86
+ const wrap = e('div', undefined, 'field');
87
+ wrap.append(e('dt', label), key ? bound('dd', key, value) : e('dd', value));
88
+ return wrap;
89
+ }
90
+ function fields(pairs) {
91
+ const list = e('dl', undefined, 'field-grid');
92
+ for (const [label, value, key] of pairs) list.append(field(label, value, key));
93
+ return list;
94
+ }
95
+ function empty(message) { return e('p', message, 'empty'); }
96
+ function excerpt(value) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 120); }
97
+ function selectedTask() { return snapshot?.tasks?.find(t => t.id === locationState.task); }
98
+ function selectedNode() {
99
+ const summary = selectedTask()?.nodes?.find(n => n.id === locationState.node);
100
+ if (!summary) return undefined;
101
+ const detail = artifactCache.get(artifactKey('node'));
102
+ return detail ? {...detail, ...summary, goal:detail.goal, last:detail.last,
103
+ planning_last:detail.planning_last, decomposition:detail.decomposition,
104
+ decomposition_history:detail.decomposition_history, error:summary.error} : summary;
105
+ }
106
+ function tabsFor() {
107
+ if (!locationState.run) return [];
108
+ if (locationState.node) return ['Overview', 'Decomposition', 'Logs', 'Changes'];
109
+ if (locationState.task) return ['Summary', 'QA', 'History'];
110
+ return ['Overview', 'Configuration', 'Commands'];
111
+ }
112
+ function activeView() {
113
+ const available = tabsFor();
114
+ return available.find(v => v.toLowerCase() === locationState.view.toLowerCase()) || available[0] || '';
115
+ }
116
+ function viewKey() { return [locationState.run, locationState.task, locationState.node, activeView()].join('/'); }
117
+ function select(next, replace = false) {
118
+ locationState = {...locationState, ...next};
119
+ if (!locationState.task) locationState.node = '';
120
+ if (next.task !== undefined || next.node !== undefined) locationState.view = '';
121
+ if (next.run !== undefined) { locationState.task = ''; locationState.node = ''; locationState.view = ''; }
122
+ if (FORGE_LIVE) history[replace ? 'replaceState' : 'pushState']({}, '', urlFor(locationState));
123
+ generation++;
124
+ currentViewKey = '';
125
+ mobileNav = false;
126
+ followLogs = false;
127
+ if (next.run !== undefined) {
128
+ snapshot = next.run === snapshot?.config?.id || (!next.run && snapshot?.runs) ? snapshot : null;
129
+ if (FORGE_LIVE) loadProgress(true);
130
+ }
131
+ render();
132
+ }
133
+ function isCurrent(resource, started, run) { return generation === started && locationState.run === run; }
134
+ async function fetchResource(resource, params, onData) {
135
+ if (!FORGE_LIVE || requests.has(resource)) return;
136
+ const started = generation;
137
+ const run = locationState.run;
138
+ const controller = new AbortController();
139
+ requests.set(resource, controller);
140
+ try {
141
+ const response = await fetch('data?' + params.toString(), {signal:controller.signal, cache:'no-store'});
142
+ if (!response.ok) throw Error('Dashboard request failed (' + response.status + ')');
143
+ const data = await response.json();
144
+ if (data.error) throw Error(data.error);
145
+ if (isCurrent(resource, started, run)) {
146
+ onData(data);
147
+ connection.textContent = livePaused ? 'Live updates paused' : 'Live · updated ' + new Date().toLocaleTimeString();
148
+ connection.classList.remove('stale');
149
+ retry.hidden = true;
150
+ }
151
+ } catch (error) {
152
+ if (error.name !== 'AbortError' && isCurrent(resource, started, run)) {
153
+ connection.textContent = 'Stale · reconnecting';
154
+ connection.classList.add('stale');
155
+ retry.hidden = false;
156
+ }
157
+ } finally { requests.delete(resource); }
158
+ }
159
+ function loadProgress(force = false) {
160
+ if (!FORGE_LIVE || (livePaused && !force)) return;
161
+ const q = new URLSearchParams({scope:locationState.run ? 'progress' : 'runs', run:locationState.run});
162
+ const key = 'progress:' + locationState.run;
163
+ fetchResource(key, q, data => {
164
+ const oldTask = snapshot?.tasks?.find(task => task.id === locationState.task);
165
+ const newTask = data.tasks?.find(task => task.id === locationState.task);
166
+ if (oldTask && newTask && oldTask.goal !== newTask.goal) artifactCache.delete(artifactKey('task'));
167
+ const oldNode = oldTask?.nodes?.find(node => node.id === locationState.node);
168
+ const newNode = newTask?.nodes?.find(node => node.id === locationState.node);
169
+ if (oldNode && newNode && oldNode.goal !== newNode.goal) artifactCache.delete(artifactKey('node'));
170
+ if (!snapshot) currentViewKey = '';
171
+ snapshot = data;
172
+ render();
173
+ if (locationState.task && activeView() === 'Logs') loadArtifact('logs', true);
174
+ if (locationState.task && activeView() === 'Changes') loadArtifact('changes', true);
175
+ if (locationState.node && ['Overview', 'Decomposition'].includes(activeView())) loadArtifact('node', true);
176
+ if (locationState.task && activeView() === 'QA') loadArtifact('qa', true);
177
+ if (locationState.task && activeView() === 'History') {
178
+ loadArtifact('history', true, historySelection().key);
179
+ }
180
+ });
181
+ }
182
+ function artifactKey(kind, extra = '') { return [locationState.run, locationState.task, locationState.node, kind, extra].join(':'); }
183
+ function historySelection() {
184
+ const table = document.getElementById('history-table')?.value || 'events';
185
+ const offset = historyOffsets.get(locationState.task + ':' + table) || 0;
186
+ return {table, offset, key:table + ':' + offset};
187
+ }
188
+ function loadArtifact(kind, refresh = false, extra = '') {
189
+ const key = artifactKey(kind, extra);
190
+ if (!FORGE_LIVE) return;
191
+ if (artifactCache.has(key) && !refresh) { if (kind !== 'node') renderArtifact(kind, artifactCache.get(key)); return; }
192
+ const requestedView = viewKey();
193
+ const q = new URLSearchParams({scope:'artifact', run:locationState.run, task:locationState.task, artifact:kind});
194
+ if (locationState.node) q.set('node', locationState.node);
195
+ if (kind === 'history') {
196
+ const [table, requestedOffset] = extra.split(':');
197
+ q.set('table', table);
198
+ q.set('offset', requestedOffset || '0');
199
+ }
200
+ fetchResource(key, q, data => {
201
+ const previous = artifactCache.get(key);
202
+ artifactCache.set(key, data);
203
+ if (requestedView !== viewKey() || (kind === 'history' && extra !== historySelection().key)) return;
204
+ if (JSON.stringify(previous) !== JSON.stringify(data)) renderArtifact(kind, data);
205
+ });
206
+ }
207
+ function runOverview() {
208
+ const tasks = snapshot?.tasks || [];
209
+ const nodes = tasks.flatMap(t => t.nodes || []);
210
+ const active = nodes.filter(n => /active|running/i.test(n.status || '')).length;
211
+ const failed = nodes.filter(n => /fail|error|stopped/i.test(n.status || '')).length;
212
+ const known = nodes.filter(n => typeof n.cost === 'number' && Number.isFinite(n.cost));
213
+ const cost = known.length ? known.reduce((sum, n) => sum + n.cost, 0).toFixed(4) + ' USD observed' : 'Unknown';
214
+ const cards = e('div', undefined, 'cards');
215
+ cards.append(card('Fractal execution', 'execution', nodes.length + ' nodes · ' + active + ' active', failed + ' problems'),
216
+ card('Forge acceptance', 'acceptance', tasks.filter(t => t.acceptance === 'PASS').length + ' / ' + tasks.length + ' passed'),
217
+ card('Verification', 'verification', tasks.filter(t => /verified|pass/i.test(t.verification || '')).length + ' / ' + tasks.length + ' verified'),
218
+ card('Observed cost', 'cost', cost, 'Unknown usage is excluded'));
219
+ body.append(section('Progress and problems', cards));
220
+ const problems = e('div'); problems.id = 'problem-list';
221
+ const summary = e('div', undefined, 'cards'); summary.id = 'task-list';
222
+ body.append(section('Problems', problems), section('Tasks', summary));
223
+ syncOverviewLists();
224
+ }
225
+ function syncOverviewLists() {
226
+ const tasks = snapshot?.tasks || [];
227
+ const problems = tasks.flatMap(t => [
228
+ ...((t.nodes || []).filter(n => /fail|error|stopped/i.test(n.status || '')).map(n => ({
229
+ key:t.id + ':' + n.id, task:t.id, node:n.id, label:n.id + ' · ' + n.status + ' · ' + t.id,
230
+ description:excerpt(n.error || n.goal)}))),
231
+ ...(/fail|error/i.test(t.state?.qa_status || '') ? [{key:t.id + ':qa', task:t.id, node:'',
232
+ label:'QA · ' + t.state.qa_status + ' · ' + t.id, description:excerpt(t.goal)}] : [])]);
233
+ const entries = tasks.map(t => ({key:t.id, task:t.id, node:'', label:t.id,
234
+ description:excerpt(t.goal) + ' · Fractal: ' + (t.state?.status || 'unknown') + ' · Forge: ' + (t.acceptance || 'pending')}));
235
+ for (const [host, records, message] of [
236
+ [document.getElementById('problem-list'), problems, 'No problems reported in the current snapshot.'],
237
+ [document.getElementById('task-list'), entries, 'No task execution has started.']]) {
238
+ if (!host) continue;
239
+ host.querySelector(':scope > .empty')?.remove();
240
+ const keys = new Set(records.map(record => record.key));
241
+ for (const child of [...host.children]) if (!keys.has(child.dataset.key)) child.remove();
242
+ records.forEach((record, index) => {
243
+ let item = [...host.children].find(child => child.dataset.key === record.key);
244
+ if (!item) {
245
+ const button = e('button', undefined, 'button');
246
+ item = panel('', button, e('p', undefined, 'small'));
247
+ item.dataset.key = record.key;
248
+ host.insertBefore(item, host.children[index] || null);
249
+ }
250
+ const button = item.querySelector('button');
251
+ setText(button, record.label);
252
+ button.onclick = () => select({task:record.task, node:record.node});
253
+ setText(item.querySelector('p'), record.description);
254
+ if (host.children[index] !== item) host.insertBefore(item, host.children[index] || null);
255
+ });
256
+ if (!records.length) host.append(empty(message));
257
+ }
258
+ }
259
+ function configView() {
260
+ const config = snapshot?.config || {};
261
+ body.append(section('Effective run configuration', panel('', fields([
262
+ ['Run ID', config.id || 'unknown'], ['Repository', config.repository || 'unknown'],
263
+ ['Backend', 'Fractal ' + (config.version || 'unknown')], ['Mode', config.mode || 'unknown']]),
264
+ e('pre', JSON.stringify(config, null, 2), 'artifact'))));
265
+ }
266
+ function commandsView() {
267
+ const id = snapshot?.config?.id || locationState.run;
268
+ body.append(e('p', 'These commands are for a terminal. The browser cannot pause or change execution.', 'notice'));
269
+ for (const command of ['status', 'tree', 'logs', 'pause', 'resume', 'stop']) {
270
+ const line = './forge fractal ' + command + ' ' + id;
271
+ const wrap = panel(command, e('pre', line)); wrap.classList.add('command');
272
+ const copy = e('button', 'Copy', 'button copy');
273
+ copy.onclick = async () => {
274
+ try { await navigator.clipboard.writeText(line); copy.textContent = 'Copied'; }
275
+ catch {
276
+ const selection = window.getSelection();
277
+ selection.removeAllRanges();
278
+ const range = document.createRange(); range.selectNodeContents(wrap.querySelector('pre'));
279
+ selection.addRange(range); copy.textContent = 'Selected';
280
+ }
281
+ };
282
+ wrap.append(copy); body.append(wrap);
283
+ }
284
+ }
285
+ function taskSummary() {
286
+ const t = selectedTask(); if (!t) return body.append(empty('This task is not present in the latest snapshot.'));
287
+ const cards = e('div', undefined, 'cards');
288
+ cards.append(card('Fractal execution', 'task-execution', t.state?.status || 'unknown'),
289
+ card('Forge acceptance', 'task-acceptance', t.acceptance || 'pending'),
290
+ card('Verification', 'task-verification', t.verification || 'unverified'),
291
+ card('QA execution', 'task-qa', t.state?.qa_status || 'unknown'));
292
+ body.append(section('Status', cards));
293
+ const prompt = t.request?.prompt || t.goal || '';
294
+ const goal = bound('pre', 'task-goal', prompt, 'artifact');
295
+ body.append(section('Task goal', goal), section('Details', panel('', fields([
296
+ ['Model', t.model || t.request?.model || 'unknown', 'task-model'],
297
+ ['Elapsed', Math.round(t.state?.elapsed || 0) + 's', 'task-elapsed'],
298
+ ['Nodes', String(t.nodes?.length || 0), 'task-nodes'],
299
+ ['Runner result', t.pipeline_exit_code ?? 'pending', 'task-exit']]))));
300
+ if (FORGE_LIVE) loadArtifact('task');
301
+ }
302
+ function taskQA() {
303
+ const t = selectedTask(); if (!t) return body.append(empty('Task unavailable.'));
304
+ body.append(section('Forge acceptance and QA', panel('', fields([
305
+ ['Acceptance', t.acceptance || 'pending', 'qa-acceptance'],
306
+ ['Verification', t.verification || 'unverified', 'qa-verification'],
307
+ ['QA execution', t.state?.qa_status || 'unknown', 'qa-status'],
308
+ ['Pipeline exit', t.pipeline_exit_code ?? 'pending', 'qa-exit']]))));
309
+ const host = e('div'); host.id = 'artifact-host'; body.append(section('QA artifacts', host));
310
+ if (FORGE_LIVE) loadArtifact('qa'); else renderArtifact('qa', t.qa || {});
311
+ }
312
+ function taskHistory() {
313
+ const t = selectedTask(); if (!t) return body.append(empty('Task unavailable.'));
314
+ const toolbar = e('div', undefined, 'toolbar');
315
+ const label = e('label', 'History source', 'small'); label.htmlFor = 'history-table';
316
+ const source = e('select'); source.id = 'history-table';
317
+ for (const name of ['events', 'steps', 'messages']) {
318
+ const option = e('option', name === 'events' ? 'Activity' : name[0].toUpperCase() + name.slice(1)); option.value = name; source.append(option);
319
+ }
320
+ source.value = source.dataset.value = selectedFiles.get('history:' + t.id) || 'events';
321
+ source.onchange = () => { selectedFiles.set('history:' + t.id, source.value); showHistory(); };
322
+ toolbar.append(label, source);
323
+ const prev = e('button', 'Previous', 'button'); prev.id = 'history-prev'; prev.onclick = () => pageHistory(-1);
324
+ const next = e('button', 'Next', 'button'); next.id = 'history-next'; next.onclick = () => pageHistory(1);
325
+ toolbar.append(prev, next, e('span', '', 'small')); toolbar.lastChild.id = 'history-page';
326
+ body.append(toolbar);
327
+ const host = e('div'); host.id = 'artifact-host'; body.append(host);
328
+ showHistory();
329
+ }
330
+ function pageHistory(direction) {
331
+ const table = document.getElementById('history-table').value;
332
+ const key = locationState.task + ':' + table;
333
+ historyOffsets.set(key, Math.max(0, (historyOffsets.get(key) || 0) + direction * 100));
334
+ showHistory();
335
+ }
336
+ function showHistory() {
337
+ const {table, offset, key} = historySelection();
338
+ const page = document.getElementById('history-page');
339
+ if (page) page.textContent = 'Rows ' + (offset + 1) + '–' + (offset + 100);
340
+ document.getElementById('history-prev').disabled = offset === 0;
341
+ const host = document.getElementById('artifact-host'); host.replaceChildren(empty('Loading history…'));
342
+ if (FORGE_LIVE) loadArtifact('history', false, key);
343
+ else {
344
+ const t = selectedTask();
345
+ const data = ({events:t.activity, steps:t.steps, messages:t.messages})[table] || {};
346
+ renderArtifact('history', {rows:(data.rows || []).slice(offset, offset + 100), complete:data.complete});
347
+ }
348
+ }
349
+ function nodeOverview() {
350
+ const n = selectedNode(); if (!n) return body.append(empty('This node is not present in the latest snapshot.'));
351
+ if (FORGE_LIVE) loadArtifact('node');
352
+ const cards = e('div', undefined, 'cards');
353
+ cards.append(card('Fractal execution', 'node-status', n.status || 'unknown'), card('Iteration', 'node-iteration', n.iteration ?? 0),
354
+ card('Observed cost', 'node-cost', n.cost == null ? 'Unknown' : n.cost + ' USD', 'Unpriced steps: ' + (n.unknown_cost_steps ?? 'unknown')));
355
+ body.append(section('Execution', cards), section('Goal', bound('pre', 'node-goal', n.goal || '', 'artifact')),
356
+ section('Scope', panel('', fields([['Model', n.model || 'unknown'], ['Parent', n.parent || 'Top level'],
357
+ ['Difficulty', n.difficulty || 'unknown'], ['Owned paths', (n.paths || []).join(', ') || 'none'],
358
+ ['Dependencies', (n.deps || []).join(', ') || 'none']]))));
359
+ if (n.error) body.append(section('Problem', panel('', bound('pre', 'node-error', n.error))));
360
+ if (n.last) body.append(section('Last response', panel('', bound('pre', 'node-last', n.last))));
361
+ }
362
+ function decisionPanel(value, title) {
363
+ const item = panel(title, fields([
364
+ ['Decision', value.decision || value.phase || 'unknown'], ['Reason', value.reason || value.error || 'No reason recorded'],
365
+ ['Phase', value.phase || 'unknown'], ['Planner', value.planner || selectedNode()?.resolved_planner || 'unknown'],
366
+ ['Attempts', value.attempts ?? 'unknown'], ['Bounds', value.bounds ? JSON.stringify(value.bounds) : 'unknown']]));
367
+ if (value.admission?.length) {
368
+ item.append(e('h3', 'Admitted children'));
369
+ for (const child of value.admission) item.append(e('p', (child.name || child.id) + ' · ' + excerpt(child.goal || child.paths?.join(', '))));
370
+ }
371
+ return item;
372
+ }
373
+ function nodeDecomposition() {
374
+ const n = selectedNode(); if (!n) return body.append(empty('Node unavailable.'));
375
+ if (FORGE_LIVE) loadArtifact('node');
376
+ if (FORGE_LIVE && !artifactCache.has(artifactKey('node'))) return body.append(empty('Loading decomposition decision…'));
377
+ if (!n.decomposition && !n.decomposition_history?.length) return body.append(empty('No decomposition decision has been recorded for this node.'));
378
+ if (n.decomposition) body.append(section('Current decision', decisionPanel(n.decomposition, 'Decision and reason')));
379
+ if (n.decomposition_history?.length) body.append(section('Earlier decisions', ...n.decomposition_history.map((d, i) => decisionPanel(d, 'Decision ' + (i + 1)))));
380
+ if (n.planning_last) body.append(section('Planner response', e('pre', n.planning_last, 'artifact')));
381
+ }
382
+ function nodeLogs() {
383
+ const toolbar = e('div', undefined, 'toolbar');
384
+ const label = e('label', 'Step and file', 'small'); label.htmlFor = 'log-file';
385
+ const selectFile = e('select'); selectFile.id = 'log-file';
386
+ selectFile.onchange = () => { selectedFiles.set(artifactKey('log-file'), selectFile.value); renderArtifact('logs', FORGE_LIVE ? artifactCache.get(artifactKey('logs')) || {} : selectedNode()?.logs || {}); };
387
+ const follow = e('button', 'Follow: off', 'button'); follow.id = 'follow-logs'; follow.setAttribute('aria-pressed', 'false');
388
+ follow.onclick = () => { followLogs = !followLogs; follow.setAttribute('aria-pressed', String(followLogs)); follow.textContent = 'Follow: ' + (followLogs ? 'on' : 'off'); if (followLogs) jumpLatest(); };
389
+ const jump = e('button', 'Jump to latest', 'button'); jump.onclick = () => { followLogs = true; follow.setAttribute('aria-pressed', 'true'); follow.textContent = 'Follow: on'; jumpLatest(); };
390
+ toolbar.append(label, selectFile, follow, jump);
391
+ const host = e('div'); host.id = 'artifact-host';
392
+ body.append(toolbar, host);
393
+ if (FORGE_LIVE) loadArtifact('logs'); else renderArtifact('logs', selectedNode()?.logs || {});
394
+ }
395
+ function jumpLatest() { const pre = document.getElementById('log-output'); if (pre) pre.scrollTop = pre.scrollHeight; }
396
+ function nodeChanges() {
397
+ const host = e('div'); host.id = 'artifact-host'; body.append(host);
398
+ if (FORGE_LIVE) loadArtifact('changes'); else renderArtifact('changes', selectedNode()?.candidate_changes || {});
399
+ }
400
+ function rawDownload(name, text) {
401
+ const button = e('button', 'Download raw', 'button');
402
+ button.onclick = () => {
403
+ const url = URL.createObjectURL(new Blob([text], {type:'text/plain;charset=utf-8'}));
404
+ const link = e('a'); link.href = url; link.download = name.replace(/[^A-Za-z0-9._-]/g, '_'); link.click();
405
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
406
+ };
407
+ return button;
408
+ }
409
+ function renderArtifact(kind, data) {
410
+ if (kind === 'node') {
411
+ const scroll = detail.scrollTop;
412
+ currentViewKey = '';
413
+ renderDetails();
414
+ detail.scrollTop = scroll;
415
+ return;
416
+ }
417
+ if (kind === 'task') {
418
+ const goal = document.querySelector('[data-bind=task-goal]');
419
+ const task = selectedTask();
420
+ if (task && data.request) task.request = data.request;
421
+ if (goal && data.request?.prompt) setText(goal, data.request.prompt);
422
+ return;
423
+ }
424
+ if (kind !== activeView().toLowerCase()) return;
425
+ const host = document.getElementById('artifact-host');
426
+ if (!host) return;
427
+ const paneScroll = detail.scrollTop;
428
+ if (kind === 'history') renderHistoryArtifact(host, data);
429
+ if (kind === 'qa') renderQaArtifact(host, data);
430
+ if (kind === 'logs') renderLogArtifact(host, data);
431
+ if (kind === 'changes') renderChangesArtifact(host, data);
432
+ detail.scrollTop = paneScroll;
433
+ }
434
+ function renderHistoryArtifact(host, data) {
435
+ const rows = data.rows || [];
436
+ const list = e('div', undefined, 'history-list');
437
+ if (data.missing) list.append(empty('History unavailable: ' + data.missing));
438
+ else if (!rows.length) list.append(empty('No history entries in this page.'));
439
+ else for (const row of rows) {
440
+ const entry = e('div', undefined, 'history-row');
441
+ entry.append(e('div', 'Entry ' + (row.id ?? '—'), 'small'), e('pre', JSON.stringify(row, null, 2)));
442
+ list.append(entry);
443
+ }
444
+ host.replaceChildren(list);
445
+ document.getElementById('history-next').disabled = rows.length < 100;
446
+ }
447
+ function renderQaArtifact(host, data) {
448
+ const wrap = e('div');
449
+ for (const [name, artifact] of Object.entries(data || {})) {
450
+ const text = artifact?.text;
451
+ wrap.append(section(name, artifact?.missing ? empty('Artifact unavailable: ' + artifact.missing) :
452
+ panel('', rawDownload(name, text || ''), e('pre', text || 'Empty artifact', 'artifact'))));
453
+ }
454
+ if (!Object.keys(data || {}).length) wrap.append(empty('No QA artifacts have been captured.'));
455
+ host.replaceChildren(wrap);
456
+ }
457
+ function renderLogArtifact(host, data) {
458
+ const files = Object.keys(data || {});
459
+ const picker = document.getElementById('log-file');
460
+ const chosen = selectedFiles.get(artifactKey('log-file')) || files[0] || '';
461
+ if (picker) {
462
+ const old = Array.from(picker.options).map(o => o.value).join('|');
463
+ if (old !== files.join('|')) {
464
+ picker.replaceChildren(...files.map(name => { const option = e('option', name); option.value = name; return option; }));
465
+ }
466
+ picker.value = files.includes(chosen) ? chosen : files[0] || '';
467
+ }
468
+ if (!files.length) { host.replaceChildren(empty('No step logs have been captured.')); return; }
469
+ const artifact = data?.[picker?.value];
470
+ const oldPre = document.getElementById('log-output');
471
+ const previous = oldPre?.dataset.file === picker.value ? oldPre.scrollTop : 0;
472
+ const atEnd = oldPre ? oldPre.scrollHeight - oldPre.clientHeight - oldPre.scrollTop < 25 : false;
473
+ const pre = e('pre', artifact?.text || (artifact?.missing ? 'Artifact unavailable: ' + artifact.missing : 'Empty log'), 'artifact');
474
+ pre.id = 'log-output'; pre.dataset.file = picker.value;
475
+ pre.addEventListener('scroll', () => {
476
+ if (followLogs && pre.scrollHeight - pre.clientHeight - pre.scrollTop > 25) {
477
+ followLogs = false;
478
+ const button = document.getElementById('follow-logs');
479
+ button.setAttribute('aria-pressed', 'false'); button.textContent = 'Follow: off';
480
+ }
481
+ });
482
+ if (oldPre && oldPre.textContent === pre.textContent && oldPre.dataset.file === pre.dataset.file) return;
483
+ host.replaceChildren(panel('', rawDownload(picker.value, artifact?.text || ''), pre));
484
+ pre.scrollTop = followLogs && (atEnd || !oldPre) ? pre.scrollHeight : previous;
485
+ }
486
+ function renderChangesArtifact(host, data) {
487
+ if (data?.missing) { host.replaceChildren(empty('Candidate diff unavailable: ' + data.missing)); return; }
488
+ const text = data?.text || '';
489
+ if (!text) { host.replaceChildren(empty('No candidate changes have been captured.')); return; }
490
+ const lines = e('pre', undefined, 'artifact');
491
+ for (const line of text.split('\n')) {
492
+ const span = e('span', line + '\n', 'diff-line');
493
+ if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@') || line.startsWith('diff ')) span.classList.add('header');
494
+ else if (line.startsWith('+')) span.classList.add('add');
495
+ else if (line.startsWith('-')) span.classList.add('remove');
496
+ lines.append(span);
497
+ }
498
+ host.replaceChildren(panel('', rawDownload('candidate.diff', text), lines));
499
+ }
500
+ function updateBoundValues() {
501
+ const t = selectedTask(), n = selectedNode();
502
+ const tasks = snapshot?.tasks || [], nodes = tasks.flatMap(item => item.nodes || []);
503
+ const values = {
504
+ execution:nodes.length + ' nodes · ' + nodes.filter(item => /active|running/i.test(item.status || '')).length + ' active',
505
+ acceptance:tasks.filter(item => item.acceptance === 'PASS').length + ' / ' + tasks.length + ' passed',
506
+ verification:tasks.filter(item => /verified|pass/i.test(item.verification || '')).length + ' / ' + tasks.length + ' verified',
507
+ cost:nodes.some(item => typeof item.cost === 'number') ? nodes.reduce((sum, item) => sum + (typeof item.cost === 'number' ? item.cost : 0), 0).toFixed(4) + ' USD observed' : 'Unknown',
508
+ 'task-execution':t?.state?.status || 'unknown', 'task-acceptance':t?.acceptance || 'pending',
509
+ 'task-verification':t?.verification || 'unverified', 'task-qa':t?.state?.qa_status || 'unknown',
510
+ 'task-model':t?.model || t?.request?.model || 'unknown',
511
+ 'task-elapsed':Math.round(t?.state?.elapsed || 0) + 's', 'task-nodes':String(t?.nodes?.length || 0),
512
+ 'task-exit':t?.pipeline_exit_code ?? 'pending',
513
+ 'qa-acceptance':t?.acceptance || 'pending', 'qa-verification':t?.verification || 'unverified',
514
+ 'qa-status':t?.state?.qa_status || 'unknown', 'qa-exit':t?.pipeline_exit_code ?? 'pending',
515
+ 'task-goal':t?.request?.prompt || t?.goal || '', 'node-status':n?.status || 'unknown',
516
+ 'node-iteration':n?.iteration ?? 0, 'node-cost':n?.cost == null ? 'Unknown' : n.cost + ' USD',
517
+ 'node-goal':n?.goal || '', 'node-error':n?.error || '', 'node-last':n?.last || ''};
518
+ for (const item of body.querySelectorAll('[data-bind]')) if (item.dataset.bind in values) {
519
+ setText(item, values[item.dataset.bind]);
520
+ if (item.dataset.tone) item.dataset.tone = tone(values[item.dataset.bind]);
521
+ }
522
+ if (!locationState.task && locationState.run && activeView() === 'Overview') syncOverviewLists();
523
+ }
524
+ function renderDetails() {
525
+ const key = viewKey();
526
+ const c = snapshot?.config;
527
+ const t = selectedTask(), n = selectedNode();
528
+ const available = !locationState.task || Boolean(t && (!locationState.node || n));
529
+ document.getElementById('eyebrow').textContent = !locationState.run ? 'Workspace' : n ? 'Fractal node' : t ? 'Forge task' : 'Run overview';
530
+ document.getElementById('detail-title').textContent = !locationState.run ? 'Managed runs' : n?.id || t?.id || c?.id || locationState.run;
531
+ document.getElementById('detail-subtitle').textContent = n ? excerpt(n.goal) : t ? excerpt(t.goal || t.request?.prompt) : c?.repository || '';
532
+ if (currentViewKey === key && currentViewAvailable === available) { updateBoundValues(); return; }
533
+ currentViewKey = key;
534
+ currentViewAvailable = available;
535
+ tabs.replaceChildren();
536
+ for (const name of tabsFor()) {
537
+ const button = e('button', name); button.type = 'button';
538
+ if (name === activeView()) button.setAttribute('aria-current', 'page');
539
+ button.onclick = () => { locationState.view = name.toLowerCase(); if (FORGE_LIVE) history.pushState({}, '', urlFor(locationState)); currentViewKey = ''; renderDetails(); };
540
+ tabs.append(button);
541
+ }
542
+ body.replaceChildren();
543
+ if (!snapshot) { body.append(empty('Loading run…')); return; }
544
+ if (!locationState.run || snapshot.runs) {
545
+ tabs.replaceChildren();
546
+ const list = e('div', undefined, 'run-list');
547
+ for (const run of snapshot.runs || []) {
548
+ const link = e(FORGE_LIVE ? 'a' : 'div', undefined, 'panel run-link');
549
+ link.append(e('strong', run.id), e('span', run.repository || 'Repository unknown'));
550
+ if (FORGE_LIVE) {
551
+ link.href = urlFor({run:run.id});
552
+ link.onclick = event => { event.preventDefault(); select({run:run.id}); };
553
+ }
554
+ list.append(link);
555
+ }
556
+ body.append(list.childElementCount ? list : empty('No managed runs yet. Start a Forge run with --fractal.'));
557
+ if (snapshot.diagnostics?.length) body.append(section('Discovery diagnostics', e('pre', JSON.stringify(snapshot.diagnostics, null, 2), 'artifact')));
558
+ return;
559
+ }
560
+ const view = activeView();
561
+ if (n) ({Overview:nodeOverview, Decomposition:nodeDecomposition, Logs:nodeLogs, Changes:nodeChanges})[view]();
562
+ else if (t) ({Summary:taskSummary, QA:taskQA, History:taskHistory})[view]();
563
+ else ({Overview:runOverview, Configuration:configView, Commands:commandsView})[view]();
564
+ updateBoundValues();
565
+ }
566
+ function makeRow(key, taskId, node, depth, hasChildren) {
567
+ const row = e('div', undefined, 'tree-row ' + (node ? 'node' : 'task'));
568
+ row.dataset.key = key;
569
+ const indent = e('span', undefined, 'indent'); indent.style.setProperty('--depth', String(depth));
570
+ const toggle = e('button', '▾', 'tree-toggle'); toggle.type = 'button'; toggle.setAttribute('aria-label', 'Collapse ' + (node?.id || taskId));
571
+ toggle.onclick = () => { if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key); renderTree(); };
572
+ const selectRow = e('button', undefined, 'tree-select'); selectRow.type = 'button';
573
+ selectRow.onclick = () => select({task:taskId, node:node?.id || ''});
574
+ const top = e('span', undefined, 'row-top');
575
+ top.append(e('span', '', 'row-label'), e('span', '', 'small row-status'), e('span', '', 'small row-model'));
576
+ selectRow.append(top, e('span', '', 'row-goal'));
577
+ row.append(indent, toggle, selectRow); rows.set(key, row);
578
+ updateRow(row, taskId, node, hasChildren);
579
+ return row;
580
+ }
581
+ function updateRow(row, taskId, node, hasChildren) {
582
+ const key = row.dataset.key;
583
+ row.classList.toggle('selected', locationState.task === taskId && locationState.node === (node?.id || ''));
584
+ const toggle = row.querySelector('.tree-toggle'); toggle.classList.toggle('empty', !hasChildren);
585
+ toggle.setAttribute('aria-expanded', String(!collapsed.has(key)));
586
+ toggle.textContent = collapsed.has(key) ? '▸' : '▾';
587
+ const selectRow = row.querySelector('.tree-select');
588
+ selectRow.setAttribute('aria-current', row.classList.contains('selected') ? 'page' : 'false');
589
+ setText(row.querySelector('.row-label'), node?.id || taskId);
590
+ const statusText = node?.status || snapshot.tasks.find(t => t.id === taskId)?.state?.status || 'unknown';
591
+ const model = node?.model || snapshot.tasks.find(t => t.id === taskId)?.model || 'unknown model';
592
+ setText(row.querySelector('.row-status'), statusText);
593
+ row.querySelector('.row-status').dataset.tone = tone(statusText);
594
+ setText(row.querySelector('.row-model'), model);
595
+ setText(row.querySelector('.row-goal'), excerpt(node?.goal || snapshot.tasks.find(t => t.id === taskId)?.goal));
596
+ selectRow.setAttribute('aria-label', (node ? 'Node ' + node.id : 'Task ' + taskId) + ', ' + statusText + ', model ' + model + ', ' + excerpt(node?.goal || snapshot.tasks.find(t => t.id === taskId)?.goal));
597
+ }
598
+ function rowsForTask(task, query) {
599
+ const nodes = task.nodes || [];
600
+ const nodeIds = new Set(nodes.map(node => node.id));
601
+ const children = new Map();
602
+ for (const node of nodes) {
603
+ const parent = nodeIds.has(node.parent) ? node.parent : null;
604
+ const siblings = children.get(parent) || [];
605
+ siblings.push(node);
606
+ children.set(parent, siblings);
607
+ }
608
+ const matches = item => [item.id, item.goal, item.model, item.status, item.state?.status]
609
+ .some(value => String(value || '').toLowerCase().includes(query));
610
+ const taskMatch = matches(task);
611
+ const visible = new Set();
612
+ function findMatches(node, ancestors) {
613
+ if (ancestors.has(node.id)) return false;
614
+ const next = new Set([...ancestors, node.id]);
615
+ let found = taskMatch || matches(node);
616
+ for (const child of children.get(node.id) || []) if (findMatches(child, next)) found = true;
617
+ if (found) visible.add(node.id);
618
+ return found;
619
+ }
620
+ for (const root of children.get(null) || []) findMatches(root, new Set());
621
+ if (query && !taskMatch && !visible.size) return [];
622
+ const taskKey = 'task:' + task.id;
623
+ const result = [[taskKey, task.id, null, 0, nodes.length > 0]];
624
+ function append(parent, depth, ancestors) {
625
+ const parentKey = parent === null ? taskKey : 'node:' + task.id + ':' + parent;
626
+ if (collapsed.has(parentKey)) return;
627
+ for (const node of children.get(parent) || []) {
628
+ if (ancestors.has(node.id) || (query && !visible.has(node.id))) continue;
629
+ const key = 'node:' + task.id + ':' + node.id;
630
+ result.push([key, task.id, node, depth, (children.get(node.id) || []).length > 0]);
631
+ append(node.id, depth + 1, new Set([...ancestors, node.id]));
632
+ }
633
+ }
634
+ append(null, 1, new Set());
635
+ return result;
636
+ }
637
+ function renderTree() {
638
+ const query = search.value.trim().toLowerCase();
639
+ const desired = (snapshot?.tasks || []).flatMap(task => rowsForTask(task, query));
640
+ const keys = new Set(desired.map(item => item[0]));
641
+ for (const [key, row] of rows) if (!keys.has(key)) { row.remove(); rows.delete(key); }
642
+ tree.querySelector(':scope > .empty')?.remove();
643
+ desired.forEach(([key, taskId, node, depth, hasChildren], index) => {
644
+ const row = rows.get(key) || makeRow(key, taskId, node, depth, hasChildren);
645
+ updateRow(row, taskId, node, hasChildren);
646
+ if (tree.children[index] !== row) tree.insertBefore(row, tree.children[index] || null);
647
+ });
648
+ document.getElementById('nav-count').textContent = desired.length + ' items';
649
+ if (!desired.length) tree.append(empty(query ? 'No matching tasks or nodes.' : 'No tasks yet.'));
650
+ }
651
+ function render() {
652
+ document.getElementById('run-heading').textContent = locationState.run || 'Runs';
653
+ document.getElementById('run-meta').textContent = snapshot?.config ? snapshot.config.repository || '' : '';
654
+ document.getElementById('all-runs').hidden = !FORGE_LIVE;
655
+ pause.hidden = !FORGE_LIVE;
656
+ search.hidden = !locationState.run;
657
+ document.querySelector('.search-label').hidden = !locationState.run;
658
+ document.getElementById('navigator').hidden = !locationState.run;
659
+ app.classList.toggle('show-detail', Boolean(locationState.run && (locationState.task || locationState.node) && !mobileNav));
660
+ if (!FORGE_LIVE) connection.textContent = 'Offline capture · ' + new Date((initial.captured_at || 0) * 1000).toLocaleString();
661
+ else if (!connection.textContent) connection.textContent = 'Connecting…';
662
+ renderTree(); renderDetails();
663
+ }
664
+ document.getElementById('all-runs').onclick = event => { event.preventDefault(); select({run:''}); };
665
+ document.getElementById('mobile-back').onclick = () => { mobileNav = true; app.classList.remove('show-detail'); search.focus(); };
666
+ search.oninput = () => renderTree();
667
+ retry.onclick = () => {
668
+ loadProgress(true);
669
+ const view = activeView();
670
+ if (view === 'Summary') loadArtifact('task', true);
671
+ if (view === 'Logs' || view === 'Changes') loadArtifact(view.toLowerCase(), true);
672
+ if (view === 'QA') loadArtifact('qa', true);
673
+ if (view === 'History') {
674
+ loadArtifact('history', true, historySelection().key);
675
+ }
676
+ };
677
+ pause.onclick = () => { livePaused = !livePaused; pause.setAttribute('aria-pressed', String(livePaused)); pause.textContent = livePaused ? 'Resume live updates' : 'Pause live updates';
678
+ connection.textContent = livePaused ? 'Live updates paused' : 'Reconnecting…';
679
+ if (livePaused) for (const controller of requests.values()) controller.abort();
680
+ else loadProgress(true);
681
+ };
682
+ window.addEventListener('popstate', () => { locationState = parseLocation(); generation++; currentViewKey = ''; mobileNav = false;
683
+ if (snapshot?.config?.id !== locationState.run && !(snapshot?.runs && !locationState.run)) { snapshot = null; loadProgress(true); }
684
+ render(); });
685
+ render();
686
+ if (FORGE_LIVE) { if (!snapshot) loadProgress(true); setInterval(() => loadProgress(), 2000); }
687
+ })();