@giamme/forge 0.9.0 → 0.11.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 (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -1,8 +1,8 @@
1
- /* forge-ui: read-only API viewer. All API values enter the DOM as text.
1
+ /* forge-ui: the page. It reads the API; a change to a run goes through the write helpers in uikit.js (see controls.js). All API values enter the DOM as text.
2
2
  Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
3
3
  run (#/runs/<key>) with its stage (3D, or the 2D graph and chart), the inspector beside it and the task table under it. The stage is one element that moves between views,
4
4
  so the 3D scene (and its WebGL context) survives navigation and refreshes. */
5
- window.ForgeUI = (() => {
5
+ window.ForgeApp = (() => {
6
6
  'use strict';
7
7
  const RM = window.ForgeRunModel;
8
8
  // an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
@@ -18,8 +18,9 @@ window.ForgeUI = (() => {
18
18
  layers: Object.assign({ cables: true, beads: true, lamps: true, roof: true, labels: true, beacons: true, finish: true, districts: true, halos: true, route: true }, store.get('layers', {})),
19
19
  replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
20
20
  lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
21
- models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
22
- files: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
21
+ settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
22
+ models: false, modelsData: null, modelsText: null, modelsError: null, reviewerOutcomes: null, modelsPrefs: store.get('models', {}),
23
+ files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
23
24
  findings: new Map() };
24
25
  state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
25
26
  if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
@@ -73,9 +74,11 @@ window.ForgeUI = (() => {
73
74
  const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
74
75
  if (c) return { key: null, sel: null, cloud: { sel: c[1] === 'runs' ? decodeURIComponent(c[2]) : null, focus: c[1] === 'projects' ? decodeURIComponent(c[2]) : null, model: c[1] === 'models' ? decodeURIComponent(c[2]) : null } };
75
76
  if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
77
+ if (/^#\/settings\/?$/.test(hash)) return { key: null, sel: null, settings: true };
76
78
  const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
77
79
  if (!m) return { key: null, sel: null };
78
80
  const d = decodeURIComponent, rest = m[2] || '';
81
+ if (/^\/findings\/?$/.test(rest)) return { key: d(m[1]), sel: null, findings: true };
79
82
  let sel = null, r;
80
83
  if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/(?!file\/|finding\/)([^/]+))?(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current', finding: r[3] ? d(r[3]) : null, file: r[4] ? d(r[4]) : null } };
81
84
  if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'fractal', task: d(r[1]), node: d(r[2]), file: r[3] ? d(r[3]) : null } };
@@ -103,9 +106,9 @@ window.ForgeUI = (() => {
103
106
  function link(text, key, sel) { const a = el('a', text); a.href = hashFor(key, sel); return a; }
104
107
 
105
108
  const statuses = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra',
106
- 'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict']);
107
- function chip(value) {
108
- const status = String(value || 'unknown').toLowerCase();
109
+ 'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict', 'accepted', 'outdated']);
110
+ function chip(value, cls) {
111
+ const status = String(cls || value || 'unknown').toLowerCase();
109
112
  return el('span', value || '—', 'chip st-' + (statuses.has(status) ? status : 'unknown'));
110
113
  }
111
114
  function time(value) {
@@ -171,8 +174,10 @@ window.ForgeUI = (() => {
171
174
  const active = parent.contains(document.activeElement) ? document.activeElement : null;
172
175
  const focusHref = active?.getAttribute('href');
173
176
  const own = [parent.scrollLeft, parent.scrollTop];
177
+ const held = window.ForgeControls ? window.ForgeControls.hold(parent) : null; // a control being edited rides through the rebuild
174
178
  parent.replaceChildren();
175
179
  build();
180
+ if (held) held.restore();
176
181
  if (!restore) return;
177
182
  for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
178
183
  for (const node of parent.querySelectorAll('[data-scroll]')) {
@@ -246,7 +251,8 @@ window.ForgeUI = (() => {
246
251
  if (!solo) {
247
252
  const verification = el('span', undefined, 'verification');
248
253
  verification.append(document.createTextNode('Verification '), chip(run.verification_status));
249
- if (run.final_review) verification.append(document.createTextNode(' Review '), chip(run.final_review));
254
+ const reviewStatus = RM.runReviewStatus(run);
255
+ if (reviewStatus) verification.append(document.createTextNode(' Review '), chip(reviewStatus));
250
256
  item.append(verification);
251
257
  }
252
258
  $('run-list').append(item);
@@ -333,7 +339,7 @@ window.ForgeUI = (() => {
333
339
  add('Kind', sel('kind', [['', 'any'], ['plan', 'plan'], ['solo', 'solo']]));
334
340
  add('Model', sel('model', [['', 'any'], ...opts.models.map(m => [m, m])]));
335
341
  add('Age', sel('ageDays', [[0, 'any'], [1, 'last day'], [7, 'last week'], [30, 'last month']]));
336
- add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']]));
342
+ add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']]));
337
343
  const all = el('input'); all.type = 'checkbox'; all.checked = state.showAll; all.dataset.f = 'showAll';
338
344
  const allL = el('label', undefined, 'filter check'); allL.append(all, document.createTextNode(' older runs too')); bar.append(allL);
339
345
  const reset = el('button', 'Reset'); reset.type = 'button'; reset.addEventListener('click', () => { Object.assign(state.mapFilter, { text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }); state.showAll = false; filterChanged(true); });
@@ -363,7 +369,12 @@ window.ForgeUI = (() => {
363
369
  card.className = `map-card st-${({ live: 'running', failed: 'fail', done: 'merged', pending: 'pending' })[m.state]}${m.match ? '' : ' dim'}`;
364
370
  card.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
365
371
  const leds = el('span', undefined, 'leds');
366
- for (const t of m.tasks.slice(0, 60)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}`; leds.append(l); }
372
+ for (const t of m.tasks.slice(0, 60)) {
373
+ const status = RM.taskStatus(t);
374
+ const l = el('i', undefined, 'led st-' + String(status || 'pending').toLowerCase());
375
+ l.title = `${t.id}: ${status}`;
376
+ leds.append(l);
377
+ }
367
378
  card.append(leds, el('span', relative(r.updated), 'muted'));
368
379
  grid.append(card);
369
380
  }
@@ -469,18 +480,61 @@ window.ForgeUI = (() => {
469
480
  runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
470
481
  savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
471
482
  tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
472
- modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
483
+ modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias), reviewerOutcomes: state.reviewerOutcomes,
473
484
  });
474
485
  }
486
+ // How each reviewer's PASSed work held up (forge dataset reviewers). Advisory and optional: read once per
487
+ // change of the models data, and a failure (an export, no dataset) only leaves the column empty.
488
+ function loadReviewerOutcomes() {
489
+ if (SNAP) return;
490
+ fetchJSON('/api/models/reviewers').then(result => { state.reviewerOutcomes = result.data; },
491
+ () => { state.reviewerOutcomes = null; }).then(() => { if (state.models) renderModels(); });
492
+ }
475
493
  function loadModels() {
476
494
  return request('/api/models', result => {
477
495
  state.modelsError = null;
478
496
  if (result.text === state.modelsText) return;
479
497
  state.modelsText = result.text; state.modelsData = result.data;
480
498
  if (state.models) renderModels();
499
+ loadReviewerOutcomes();
481
500
  }, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
482
501
  }
483
502
 
503
+ /* ---------- the settings (#/settings) ---------- */
504
+ function renderSettings() {
505
+ const main = $('main');
506
+ if (main.dataset.view === 'settings') { state.settingsView?.poll(); return; } // already drawn: its unsaved edits stay
507
+ setView(main, 'settings'); delete main.dataset.run;
508
+ main.replaceChildren();
509
+ const v = el('section', undefined, 'settings-view'); v.id = 'settings-view'; main.append(v);
510
+ state.settingsView = window.ForgeSettings.render(v, {
511
+ // a pick holds only while the current run is the one it was made from: the default is the current or most recent run
512
+ key: state.settingsRun && state.lastKey === state.settingsPickedAt ? state.settingsRun : state.lastKey,
513
+ runs: () => state.runs, onPick: key => { state.settingsRun = key; state.settingsPickedAt = state.lastKey; },
514
+ });
515
+ }
516
+
517
+ /* ---------- a run's open findings (#/runs/<key>/findings) ---------- */
518
+ function renderFindings() {
519
+ const main = $('main');
520
+ if (main.dataset.view === 'findings' && main.dataset.run === state.key) return; // already drawn: a toggle elsewhere must not reload it
521
+ setView(main, 'findings'); main.dataset.run = state.key;
522
+ main.replaceChildren();
523
+ const v = el('section', undefined, 'findings-view'); v.id = 'findings-view'; main.append(v);
524
+ window.ForgeFindings.render(v, {
525
+ key: state.key, run: state.cache.get(state.key)?.data, back: '#/runs/' + encodeURIComponent(state.key),
526
+ tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
527
+ onCount: (n, scope) => { if (scope !== 'run') return; state.findingsCount.set(state.key, n); },
528
+ });
529
+ }
530
+ function findingsLink(key) {
531
+ const a = el('a', undefined, 'details-btn findings-link'); a.href = '#/runs/' + encodeURIComponent(key) + '/findings';
532
+ a.title = 'The findings this run left unfixed: dismiss them or file them as issues';
533
+ const n = state.findingsCount.get(key);
534
+ a.append(icon('panel-right'), el('span', 'Open findings'), el('span', n === undefined ? '' : String(n), 'findings-count'));
535
+ return a;
536
+ }
537
+
484
538
  /* ---------- a run ---------- */
485
539
  function viewToggle() {
486
540
  const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
@@ -581,14 +635,32 @@ window.ForgeUI = (() => {
581
635
  rec.append(icon('panel-right'), el('span', 'Run details'));
582
636
  rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
583
637
  acts.append(rec);
638
+ if (!SNAP && window.ForgeFindings) acts.append(findingsLink(state.key));
584
639
  if (!SNAP) {
585
640
  const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
586
641
  ex.append(icon('download'), el('span', 'Export…'));
587
642
  ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
588
643
  }
644
+ // the delete key stands apart from the routine ones, and only a server that takes writes shows it
645
+ if (!SNAP && window.ForgeRunDelete && window.ForgeUI && window.ForgeUI.controlsEnabled) {
646
+ window.ForgeUI.controlsEnabled().then(on => {
647
+ if (!on || acts.querySelector('.del-key')) return;
648
+ const del = el('button', undefined, 'del-key'); del.type = 'button'; del.title = 'Delete this run, after a dry run and a typed confirmation';
649
+ del.append(el('i', undefined, 'lamp st-fail'), el('span', 'Delete run…'));
650
+ del.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) window.ForgeRunDelete.open(run, { onDeleted: runDeleted }); });
651
+ acts.append(el('span', undefined, 'del-gap'), del);
652
+ });
653
+ }
589
654
  bar.append(rp, acts);
590
655
  header.append(bar);
591
656
  }
657
+ // after a delete: the run is gone, so the page returns to the run list and drops what it held of the run
658
+ function runDeleted(run) {
659
+ state.cache.delete(run.key);
660
+ if (location.hash !== '#/') location.hash = '#/';
661
+ state.listText = null;
662
+ loadList();
663
+ }
592
664
  // the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
593
665
  function syncLayerKeys() {
594
666
  for (const b of document.querySelectorAll('.layer-key')) {
@@ -683,11 +755,11 @@ window.ForgeUI = (() => {
683
755
  { id: 'title', label: 'Title', value: t => t.title },
684
756
  { id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
685
757
  { id: 'wave', label: 'Wave', value: t => Number.isFinite(Number(t.wave)) && t.wave !== null && t.wave !== '' ? Number(t.wave) : null, cell: t => display(t.wave) },
686
- { id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
758
+ { id: 'status', label: 'Status', value: t => RM.taskStatus(t), cell: t => chip(RM.taskStatus(t)) },
687
759
  { id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
688
760
  { id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
689
761
  { id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
690
- { id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
762
+ { id: 'verdict', label: 'Verdict', value: t => RM.taskVerdict(t).text, cell: t => { const v = RM.taskVerdict(t); return chip(v.text, v.status); } },
691
763
  { id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
692
764
  value: t => t.findings && t.findings.worst ? SEV_RANK[t.findings.worst] * 1000 - (t.findings[t.findings.worst] || 0) : t.findings && t.findings.plausible ? 4000 : null,
693
765
  cell: t => { const f = t.findings, v = sev(f); if (!f || !f.worst) return v; const n = el('span', v, 'sev-chip sev-' + f.worst); return n; } },
@@ -862,6 +934,8 @@ window.ForgeUI = (() => {
862
934
  if (!state.key) document.body.classList.remove('no-tasks');
863
935
  if (state.cloud) { renderCloud(); return; }
864
936
  if (state.models) { renderModels(); return; }
937
+ if (state.settings) { renderSettings(); return; }
938
+ if (state.key && state.findingsPage) { renderFindings(); return; }
865
939
  if (!state.key) { renderMap(); return; }
866
940
  const cached = state.cache.get(state.key);
867
941
  if (cached) renderRun(cached.data);
@@ -916,16 +990,17 @@ window.ForgeUI = (() => {
916
990
  if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
917
991
  renderList();
918
992
  if (state.cloud) renderCloud();
919
- else if (!state.key) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
993
+ else if (state.settings) state.settingsView?.poll(); // the picker follows the run list; the page stays
994
+ else if (!state.key && !state.models) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
920
995
  }, 'Could not refresh runs');
921
996
  }
922
997
  function loadRun(key) {
923
998
  return request('/api/runs/' + encodeURIComponent(key), result => {
924
999
  const changed = state.cache.get(key)?.text !== result.text;
925
1000
  state.cache.set(key, result);
926
- if (state.key === key && changed) renderRun(result.data);
1001
+ if (state.key === key && changed && !state.findingsPage) renderRun(result.data);
927
1002
  }, 'Could not refresh run', error => {
928
- if (state.key !== key || state.cache.has(key)) return;
1003
+ if (state.key !== key || state.cache.has(key) || state.findingsPage) return;
929
1004
  if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
930
1005
  setView($('main'), 'waiting');
931
1006
  $('main').replaceChildren(el('p', error.status === 404 ?
@@ -940,7 +1015,7 @@ window.ForgeUI = (() => {
940
1015
  history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
941
1016
  parsed = parseHash(location.hash);
942
1017
  }
943
- const keyChanged = parsed.key !== state.key;
1018
+ const keyChanged = parsed.key !== state.key || (!!state.findingsPage && !parsed.findings); // the findings page replaced the run's DOM
944
1019
  state.key = parsed.key;
945
1020
  const selWas = JSON.stringify(state.sel || null);
946
1021
  state.sel = parsed.sel;
@@ -951,6 +1026,9 @@ window.ForgeUI = (() => {
951
1026
  state.convo = parsed.convo || null;
952
1027
  state.cloud = parsed.cloud || null;
953
1028
  state.models = !!parsed.models;
1029
+ state.settings = !!parsed.settings && !!window.ForgeSettings && !SNAP;
1030
+ if (parsed.key) state.lastKey = parsed.key;
1031
+ state.findingsPage = !!parsed.findings && !!window.ForgeFindings;
954
1032
  if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
955
1033
  cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
956
1034
  history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
@@ -958,13 +1036,15 @@ window.ForgeUI = (() => {
958
1036
  document.body.classList.toggle('cloud-mode', !!state.cloud);
959
1037
  if (!state.key) document.body.classList.remove('no-tasks');
960
1038
  for (const a of document.querySelectorAll('.topbar .top-link')) {
961
- const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
1039
+ const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : state.settings ? '#/settings' : !state.key ? '#/' : null);
962
1040
  if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
963
1041
  }
964
1042
  renderErrors();
965
1043
  markSelection();
966
1044
  if (state.cloud) { renderCloud(); return; }
967
1045
  if (state.models) { renderModels(); loadModels(); return; }
1046
+ if (state.settings) { renderSettings(); return; }
1047
+ if (state.key && state.findingsPage) { renderFindings(); return; }
968
1048
  if (!state.key) { renderMap(); return; }
969
1049
  const cached = state.cache.get(state.key);
970
1050
  if (cached) {
@@ -993,6 +1073,7 @@ window.ForgeUI = (() => {
993
1073
  loadList();
994
1074
  if (state.key) loadRun(state.key);
995
1075
  if (state.models) loadModels();
1076
+ if (state.settings) state.settingsView?.poll();
996
1077
  for (const node of document.querySelectorAll('[data-epoch]')) { const t = relative(node.dataset.epoch); node.textContent = node.dataset.short !== undefined ? t.replace(/^updated /, '') : t; }
997
1078
  }
998
1079
  // Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
@@ -1008,12 +1089,14 @@ window.ForgeUI = (() => {
1008
1089
  { id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
1009
1090
  { id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
1010
1091
  { id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
1092
+ ...(window.ForgeSettings ? [{ id: 'cmd:settings', label: 'Open the settings', sub: 'model registry, Jev, and the .forge files of a repository', ids: ['settings', 'config', 'verify', 'jev'], href: '#/settings' }] : []),
1011
1093
  { id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
1012
1094
  { id: 'cmd:dim', label: state.dim === '3d' ? 'Switch to 2D' : 'Switch to 3D', sub: state.dim === '3d' ? 'graphs and charts, no WebGL' : 'loads three.js from cdn.jsdelivr.net', ids: ['2d', '3d'],
1013
1095
  action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
1014
1096
  { id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
1015
1097
  action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
1016
1098
  ];
1099
+ if (state.key && !SNAP && window.ForgeFindings) out.push({ id: 'cmd:findings', label: 'Open findings of this run…', sub: 'dismiss them or file them as GitHub issues', ids: ['findings', 'issues', 'dismiss'], href: '#/runs/' + encodeURIComponent(state.key) + '/findings' });
1017
1100
  if (run && !SNAP) out.push({ id: 'cmd:export', label: 'Export this run as one HTML file…', sub: 'to share; opens without forge-ui', ids: ['export', 'share'], action: () => exportDialog(run) });
1018
1101
  if (SNAP) return out.filter(c => c.id === 'cmd:dim').concat(run ? [{ id: 'cmd:mode', label: state.mode === 'plan' ? 'Show the execution' : 'Show the planned waves', sub: '', ids: ['exec', 'plan'], action: () => setMode(state.mode === 'plan' ? 'exec' : 'plan') }] : []);
1019
1102
  if (run && window.ForgeFiles) out.push({ id: 'cmd:files', label: 'Show which tasks touched which files', sub: 'the Files view of this run', ids: ['files', 'heatmap'], action: () => setMode('files') });
@@ -1077,3 +1160,6 @@ window.ForgeUI = (() => {
1077
1160
  init();
1078
1161
  return { refresh, state };
1079
1162
  })();
1163
+ // window.ForgeUI is uikit.js's shared helpers; the page's own handles (state, refresh) join it, where the browser
1164
+ // tests and ui/tools/readme_media.py read them.
1165
+ window.ForgeUI = Object.assign(window.ForgeUI || {}, window.ForgeApp);
@@ -427,9 +427,12 @@ export function createCloud(E, host, cb = {}) {
427
427
  function info(el) {
428
428
  const f = el.f, r = f.run, counts = Object.entries(r.counts || {}).map(([s, n]) => `${s.toLowerCase()} ${n}`).join(' · ');
429
429
  const proj = st.L && st.L.projects.find(p => p.id === f.project);
430
+ const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
431
+ const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
432
+ const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : null;
430
433
  return [['meta', `${proj ? proj.name : RM.basename(r.repo)} · ${r.run_id || ''} · ${r.kind}`], ['title', String(r.goal || 'Untitled run').split('\n')[0]],
431
434
  ['body', `${f.n} task${f.n > 1 ? 's' : ''} · ${counts}`], ['body', `${[...f.dwarf].join(', ') || '—'} → ${[...f.qa].join(', ') || '—'}`],
432
- ['foot', `started ${new Date(f.t * 1000).toLocaleString()}${r.final_review ? ' · review ' + r.final_review : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
435
+ ['foot', `started ${new Date(f.t * 1000).toLocaleString()}${reviewText ? ' · review ' + reviewText : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
433
436
  ['foot', (store.layouts[st.layout] || {})[el.key] ? 'moved by you · drag to move, Shift = depth, Alt = height' : 'drag to move · double-click to open']];
434
437
  }
435
438
  function showTip(el, cx, cy) {
@@ -63,7 +63,7 @@
63
63
  const aliases = specs => new Set(list(specs).map(RM.alias).filter(Boolean));
64
64
  return { key: run.key, run, project: hashId(run.repo || '?'), n, bucket: bucket(n), diff, dominant, state, t: startOf(run),
65
65
  dwarf: aliases(run.dwarf_models || run.models), qa: aliases(run.qa_models || []), all: aliases(run.models),
66
- review: run.final_review || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
66
+ review: RM.runReviewStatus(run) || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
67
67
  }
68
68
 
69
69
  /* ---------- filters: the cloud shows exactly the runs that match ---------- */
@@ -254,7 +254,7 @@ window.ForgeCloudUI = (() => {
254
254
  }
255
255
  const more = el('fieldset', undefined, 'cloud-field cloud-more'); more.append(el('legend', 'More'));
256
256
  more.append(select('Active', 'active', [[0, 'any time'], [1, 'in the last day'], [7, 'in the last week'], [30, 'in the last month'], [90, 'in the last 3 months']], () => f.activeDays, v => { f.activeDays = Number(v) || 0; }));
257
- more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
257
+ more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
258
258
  more.append(select('Verification', 'verification', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'not run']], () => f.verification, v => { f.verification = v; }));
259
259
  const dec = Object.keys(O.decompose);
260
260
  if (dec.length > 1) more.append(chips('Decompose', dec.map(k => [k, k === 'none' ? 'off' : k, O.decompose[k]]), () => f.decompose, v => { f.decompose = v; }));
@@ -290,13 +290,22 @@ window.ForgeCloudUI = (() => {
290
290
  head.append(shut);
291
291
  card.append(head, el('h2', String(r.goal || 'Untitled run').split('\n')[0]), el('p', r.run_id || '', 'mono muted'));
292
292
  const leds = el('span', undefined, 'leds');
293
- for (const t of list(r.task_statuses).slice(0, 120)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}${t.title ? ' · ' + t.title : ''}`; leds.append(l); }
293
+ for (const t of list(r.task_statuses).slice(0, 120)) {
294
+ const st = RM.taskStatus(t);
295
+ const l = el('i', undefined, 'led st-' + String(st || 'pending').toLowerCase());
296
+ l.title = `${t.id}: ${st}${t.title ? ' · ' + t.title : ''}`;
297
+ leds.append(l);
298
+ }
294
299
  card.append(leds);
295
300
  const dl = el('dl', undefined, 'fields');
296
301
  const mix = ['high', 'medium', 'low', 'none'].filter(k => f.diff[k]).map(k => `${f.diff[k]} ${k === 'none' ? 'unset' : k}`).join(' · ');
297
302
  const plates = specs => { if (!specs.length || !window.ForgeIcons) return specs.join(', ') || '—'; const s = el('span', undefined, 'plates'); for (const x of specs) s.append(window.ForgeIcons.modelPlate(x)); return s; };
303
+ const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
304
+ const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
305
+ const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : '—';
306
+ const reviewVal = isOutdated ? el('span', reviewText, 'chip st-outdated') : reviewText;
298
307
  for (const [k, v, mono] of [['Tasks', `${f.n}${mix ? ' · ' + mix : ''}`], ['Implementers', plates([...f.dwarf])], ['Reviewers', plates([...f.qa])],
299
- ['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', r.final_review || '—'], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
308
+ ['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', reviewVal], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
300
309
  const dd = el('dd', undefined, mono ? 'mono' : ''); dd.append(v instanceof Node ? v : document.createTextNode(String(v))); dl.append(el('dt', k), dd);
301
310
  }
302
311
  card.append(dl);
@@ -345,7 +354,7 @@ window.ForgeCloudUI = (() => {
345
354
  a.href = '#/runs/' + encodeURIComponent(r.key);
346
355
  a.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''} · ${f.n} task${f.n === 1 ? '' : 's'}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
347
356
  const leds = el('span', undefined, 'leds');
348
- for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()));
357
+ for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(RM.taskStatus(t) || 'pending').toLowerCase()));
349
358
  a.append(leds); grid.append(a);
350
359
  }
351
360
  g.append(grid); listHost.append(g);