@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
@@ -0,0 +1,438 @@
1
+ /* forge-ui open findings: the findings a run (or every run of its repo) left open, to dismiss or to file as GitHub issues
2
+ (window.ForgeFindings, and a CommonJS module under node:test). Route #/runs/<key>/findings.
3
+ The pure helpers (status, filters, selection rules, request URLs and bodies, merging the online `filed` data) are
4
+ tested in ui/tests/js/findingsview.test.js; render(host, ctx) is thin DOM over them. Finding and issue texts are
5
+ untrusted: they only ever enter the page as text.
6
+ ctx = { key, run? (the cached run, for the title), back (href of the run), tablePrefs?(id), saveTablePrefs?(id, prefs),
7
+ onCount?(openRows, scope) }. The write calls are ForgeUI's; without controls the page shows no write control. */
8
+ (function (root, factory) {
9
+ 'use strict';
10
+ const api = factory(root);
11
+ if (typeof module === 'object' && module.exports) module.exports = api;
12
+ else root.ForgeFindings = api;
13
+ })(typeof self !== 'undefined' ? self : this, function (root) {
14
+ 'use strict';
15
+
16
+ const MAX_FPS = 25, MAX_LABELS = 5, MAX_LABEL = 50, MAX_REASON = 500;
17
+ const list = v => Array.isArray(v) ? v : [];
18
+ const SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
19
+ const ORIGINS = ['tolerated', 'accepted', 'review', 'plausible'];
20
+ const STATUSES = ['open', 'filed', 'fixed', 'dismissed'];
21
+
22
+ /* ---------- pure ---------- */
23
+ // open | filed (its issue is OPEN, or its state is not known yet) | fixed (CLOSED) | dismissed. A dismissal wins, so
24
+ // that an Undismiss reaches every dismissed row.
25
+ function statusOf(f) {
26
+ if (f && f.dismissed) return 'dismissed';
27
+ if (f && f.filed) return String(f.filed.state || '').toUpperCase() === 'CLOSED' ? 'fixed' : 'filed';
28
+ return 'open';
29
+ }
30
+ // filter = { status: 'open' (default) | 'all' | one of STATUSES, severity: 'all' | P0..P3, origin: 'all' | ORIGINS }
31
+ function filterRows(rows, filter) {
32
+ const f = filter || {}, status = f.status || 'open', severity = f.severity || 'all', origin = f.origin || 'all';
33
+ // `dismissed` (optional): false hides dismissed rows whatever the status; true adds them to the default 'open' view.
34
+ return list(rows).filter(r => {
35
+ const st = statusOf(r);
36
+ if (f.dismissed === false && st === 'dismissed') return false;
37
+ const byStatus = status === 'all' || st === status || (f.dismissed === true && status === 'open' && st === 'dismissed');
38
+ return byStatus && (severity === 'all' || r.severity === severity) && (origin === 'all' || r.origin === origin);
39
+ });
40
+ }
41
+ // The online listing knows which findings have an issue: its `filed` goes into the offline rows by fingerprint.
42
+ // A row only the online listing has is added; a row only the offline listing has stays as it was.
43
+ function mergeFiled(offline, online) {
44
+ const by = new Map(list(online).map(r => [r.fp, r]));
45
+ const seen = new Set();
46
+ const rows = list(offline).map(r => {
47
+ seen.add(r.fp);
48
+ const o = by.get(r.fp);
49
+ return o ? { ...r, filed: o.filed || null } : r;
50
+ });
51
+ for (const r of list(online)) if (!seen.has(r.fp)) rows.push(r);
52
+ return rows;
53
+ }
54
+ // Which rows an action takes: filing and dismissing take open rows, Undismiss takes dismissed rows.
55
+ function selectable(action, row) {
56
+ const status = statusOf(row);
57
+ return action === 'undismiss' ? status === 'dismissed' : (action === 'file' || action === 'dismiss') && status === 'open';
58
+ }
59
+ // What `selected` (a Set of fingerprints) means for one action: the fingerprints it takes, and whether the button
60
+ // is enabled (1 to 25) with the note to show when it is not.
61
+ function selection(action, rows, selected) {
62
+ const fps = list(rows).filter(r => selected.has(r.fp) && selectable(action, r)).map(r => r.fp);
63
+ const n = fps.length;
64
+ const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
65
+ return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
66
+ }
67
+ const bit = v => v ? '1' : '0';
68
+ // The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
69
+ function issuesUrl(key, o) {
70
+ const q = [`scope=${o.scope === 'repo' ? 'repo' : 'run'}`, `offline=${bit(o.offline)}`, `plausible=${bit(o.plausible)}`];
71
+ if (o.dismissed) q.push('dismissed=1');
72
+ if (list(o.only).length) q.push('only=' + o.only.join(','));
73
+ return `/api/runs/${encodeURIComponent(key)}/issues?${q.join('&')}`;
74
+ }
75
+ const issuesPath = key => `/api/runs/${encodeURIComponent(key)}/issues`;
76
+ const dismissPath = key => issuesPath(key) + '/dismiss';
77
+ // "a, b" → {labels, error}: at most 5, each 1 to 50 characters, no control character, not starting with "-".
78
+ function parseLabels(text) {
79
+ const labels = String(text || '').split(',').map(s => s.trim()).filter(Boolean);
80
+ if (labels.length > MAX_LABELS) return { labels: [], error: `at most ${MAX_LABELS} labels` };
81
+ for (const l of labels) {
82
+ if (l.length > MAX_LABEL) return { labels: [], error: `a label is at most ${MAX_LABEL} characters` };
83
+ if (l.startsWith('-')) return { labels: [], error: 'a label cannot start with "-"' };
84
+ if (/[\u0000-\u001f\u007f]/.test(l)) return { labels: [], error: 'a label cannot hold control characters' };
85
+ }
86
+ return { labels, error: null };
87
+ }
88
+ function fileBody(scope, fps, includePlausible, labels) {
89
+ return { scope: scope === 'repo' ? 'repo' : 'run', fingerprints: list(fps).slice(), include_plausible: !!includePlausible, labels: list(labels).slice() };
90
+ }
91
+ // `reason` goes with a dismissal only; Undismiss has none.
92
+ function dismissBody(fps, reason, undo) {
93
+ const body = { fingerprints: list(fps).slice() };
94
+ if (undo) { body.undo = true; return body; }
95
+ const r = String(reason || '').trim();
96
+ if (r) body.reason = r.slice(0, MAX_REASON);
97
+ return body;
98
+ }
99
+ // The POST's `created` as lines: {kind: 'created' | 'skipped' | 'note', text, url?}
100
+ function resultLines(created) {
101
+ return list(created).map(c => {
102
+ if (c && c.url) return { kind: 'created', text: c.title || c.url, url: c.url };
103
+ if (c && c.skipped) return { kind: 'skipped', text: `${c.title || 'finding'}: skipped, ${c.skipped}`, url: c.issue || null };
104
+ return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
105
+ });
106
+ }
107
+ // An untrusted URL becomes a link only when it is a plain https address.
108
+ const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
109
+ // The marker for a finding whose whole-run review is outdated, or null. The lamp is the hollow unlit
110
+ // `.st-outdated`. `title` is the same sentence the issue body adds, ids cut after 5, so a row can show it
111
+ // without building HTML. A current review, and any finding that is not from a whole-run review, has none.
112
+ function reviewMarker(row) {
113
+ const review = row && row.review;
114
+ if (!review || review.outdated !== true) return null;
115
+ const ids = list(review.merged_since).map(id => String(id));
116
+ const shown = ids.slice(0, 5), extra = ids.length - shown.length;
117
+ const names = shown.join(', ') + (extra > 0 ? ', +' + extra + ' more' : '');
118
+ const n = ids.length;
119
+ const sentence = `The review is outdated: ${n} ${n === 1 ? 'task' : 'tasks'} merged into the integration branch after it`
120
+ + (names ? ` (${names})` : '') + ', so check whether they fixed it first.';
121
+ return { lamp: 'st-outdated', label: 'outdated review', title: sentence, sentence };
122
+ }
123
+
124
+ /* ---------- DOM ---------- */
125
+ function render(host, ctx) {
126
+ const UI = root.ForgeUI;
127
+ const el = (tag, text, cls) => UI.el(tag, text, cls);
128
+ const s = { scope: 'run', plausible: false, dismissed: false, filter: { status: 'open', severity: 'all', origin: 'all' },
129
+ rows: [], selected: new Set(), seq: 0, writes: false, busy: false };
130
+ const key = ctx.key;
131
+
132
+ const head = el('div', undefined, 'fnd-head');
133
+ const title = el('h1', 'Open findings', 'module-title');
134
+ const back = el('a', 'Back to the run', 'fnd-back'); back.href = ctx.back || '#/';
135
+ head.append(title, back);
136
+ const lede = el('p', undefined, 'muted fnd-lede');
137
+ const scopeBar = el('div', undefined, 'seg fnd-scope'); scopeBar.setAttribute('role', 'group'); scopeBar.setAttribute('aria-label', 'Scope');
138
+ const scopeKeys = {};
139
+ for (const [v, label] of [['run', 'This run'], ['repo', 'All runs of this repo']]) {
140
+ const b = el('button', label); b.type = 'button';
141
+ b.addEventListener('click', () => { if (s.scope === v) return; s.scope = v; s.selected.clear(); load(); });
142
+ scopeKeys[v] = b; scopeBar.append(b);
143
+ }
144
+ const lamp = el('p', undefined, 'fnd-lamp'); lamp.setAttribute('role', 'status');
145
+ const notes = el('div', undefined, 'fnd-notes muted');
146
+
147
+ // filters
148
+ const filters = el('div', undefined, 'fnd-filters');
149
+ const select = (label, options, get, set) => {
150
+ const l = el('label', undefined, 'fnd-field'), sel = el('select');
151
+ for (const [v, t] of options) { const o = el('option', t); o.value = v; sel.append(o); }
152
+ sel.value = get();
153
+ sel.addEventListener('change', () => { set(sel.value); drawTable(); });
154
+ l.append(el('span', label, 'plate'), sel);
155
+ return l;
156
+ };
157
+ const toggle = (label, get, set) => {
158
+ const l = el('label', undefined, 'fnd-toggle'), c = el('input');
159
+ c.type = 'checkbox'; c.checked = get();
160
+ c.addEventListener('change', () => { set(c.checked); load(); });
161
+ l.append(c, el('span', label));
162
+ return l;
163
+ };
164
+ filters.append(
165
+ select('Status', [['open', 'open'], ['all', 'all'], ...STATUSES.slice(1).map(x => [x, x])], () => s.filter.status, v => { s.filter.status = v; }),
166
+ select('Severity', [['all', 'all'], ...SEVERITIES.map(x => [x, x])], () => s.filter.severity, v => { s.filter.severity = v; }),
167
+ select('Origin', [['all', 'all'], ...ORIGINS.map(x => [x, x])], () => s.filter.origin, v => { s.filter.origin = v; }),
168
+ toggle('Include plausible', () => s.plausible, v => { s.plausible = v; }),
169
+ toggle('Show dismissed', () => s.dismissed, v => { s.dismissed = v; }));
170
+
171
+ // actions (only with controls)
172
+ const actions = el('div', undefined, 'fnd-actions'); actions.hidden = true;
173
+ actions.setAttribute('role', 'group'); actions.setAttribute('aria-label', 'Actions on the selected findings');
174
+ const key_ = (text, cls, fn) => { const b = el('button', text, cls); b.type = 'button'; b.addEventListener('click', fn); return b; };
175
+ const fileKey = key_('File issues', 'primary', () => openFile());
176
+ const dismissKey = key_('Dismiss', undefined, () => openDismiss());
177
+ const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
178
+ const allKey = key_('Select shown open', undefined, () => {
179
+ for (const r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
180
+ drawTable(); syncActions();
181
+ });
182
+ const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
183
+ const actionNote = el('span', undefined, 'fnd-note muted'); actionNote.setAttribute('aria-live', 'polite');
184
+ actions.append(allKey, noneKey, fileKey, dismissKey, undismissKey, actionNote);
185
+ const reasonHost = el('div', undefined, 'fnd-reason');
186
+ const outcomeHost = el('div', undefined, 'fnd-outcome');
187
+ const tableHost = el('div', undefined, 'fnd-table');
188
+ const readOnly = el('p', 'read-only: findings can be read here, not filed or dismissed.', 'muted fnd-readonly'); readOnly.hidden = true;
189
+
190
+ host.replaceChildren(head, lede, scopeBar, lamp, notes, filters, readOnly, actions, reasonHost, outcomeHost, tableHost);
191
+ let posting = false; // a filing POST is running: the dialog stays open until its results are shown
192
+ const dlg = el('dialog', undefined, 'fnd-dialog'); dlg.setAttribute('aria-label', 'File issues on GitHub');
193
+ host.append(dlg);
194
+
195
+ const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
196
+ function syncScope() {
197
+ for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
198
+ lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
199
+ : 'Findings every run of this repository left unfixed.';
200
+ }
201
+ function syncActions() {
202
+ actions.hidden = !s.writes;
203
+ if (!s.writes) return;
204
+ const f = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
205
+ fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
206
+ fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
207
+ undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
208
+ actionNote.textContent = f.note || d.note || u.note || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
209
+ }
210
+ function setLamp(cls, text) {
211
+ lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
212
+ }
213
+
214
+ // cells
215
+ const originCell = r => {
216
+ const marker = reviewMarker(r);
217
+ if (!marker) return r.origin || '—';
218
+ const box = el('span', undefined, 'fnd-status');
219
+ const lamp = el('i', undefined, 'lamp ' + marker.lamp);
220
+ lamp.title = marker.title;
221
+ box.title = marker.title;
222
+ box.append(lamp, el('span', r.origin || '—'), el('span', marker.label));
223
+ return box;
224
+ };
225
+ const statusCell = r => {
226
+ const st = statusOf(r), box = el('span', undefined, 'fnd-status fnd-' + st);
227
+ box.append(el('i', undefined, 'lamp ' + (st === 'filed' ? 'fnd-amber' : st === 'fixed' ? 'st-pass' : 'lamp-off')));
228
+ if (st === 'dismissed') { box.title = r.dismissed.reason ? String(r.dismissed.reason) : 'dismissed'; box.append(el('span', 'dismissed')); return box; }
229
+ if (st === 'open') { box.append(el('span', 'open')); return box; }
230
+ const url = safeUrl(r.filed && r.filed.url), word = st === 'filed' ? 'filed' : 'fixed';
231
+ if (url) { const a = el('a', word); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.title = 'the GitHub issue (' + String(r.filed.state || 'state unknown').toLowerCase() + ')'; box.append(a); }
232
+ else box.append(el('span', word));
233
+ return box;
234
+ };
235
+ const selectCell = r => {
236
+ const c = el('input'); c.type = 'checkbox';
237
+ c.setAttribute('aria-label', 'Select finding: ' + String(r.summary || r.fp).slice(0, 80));
238
+ c.checked = s.selected.has(r.fp);
239
+ c.disabled = !selectable('file', r) && !selectable('undismiss', r);
240
+ c.addEventListener('change', () => { if (c.checked) s.selected.add(r.fp); else s.selected.delete(r.fp); syncActions(); });
241
+ return c;
242
+ };
243
+ const summaryCell = r => {
244
+ const box = el('div', undefined, 'fnd-summary'), line = el('div', undefined, 'fnd-summary-line');
245
+ const more = el('button', undefined, 'fnd-more'); more.type = 'button';
246
+ more.setAttribute('aria-expanded', 'false'); more.setAttribute('aria-label', 'Show the full text');
247
+ more.append(el('span', '▸', 'fnd-caret'));
248
+ const detail = el('div', undefined, 'fnd-detail'); detail.hidden = true;
249
+ detail.append(el('pre', r.text || r.summary || '', 'fnd-text'));
250
+ const marker = reviewMarker(r);
251
+ if (marker) detail.append(el('p', marker.sentence, 'muted'));
252
+ const meta = el('dl', undefined, 'fnd-meta');
253
+ for (const [k, v] of [['Reviewer', r.reviewer], ['Commit', r.commit], ['Fingerprint', r.fp], ['Plan', r.plan_dir || r.plan],
254
+ ['Dismissed', r.dismissed ? `${r.dismissed.at || ''} ${r.dismissed.reason || ''}`.trim() : null]]) {
255
+ if (v) meta.append(el('dt', k), el('dd', v));
256
+ }
257
+ detail.append(meta);
258
+ more.addEventListener('click', () => {
259
+ const open = detail.hidden; detail.hidden = !open;
260
+ more.setAttribute('aria-expanded', String(open)); more.setAttribute('aria-label', open ? 'Hide the full text' : 'Show the full text');
261
+ more.firstChild.textContent = open ? '▾' : '▸';
262
+ });
263
+ line.append(more, el('span', r.summary || r.text || '—'));
264
+ box.append(line, detail);
265
+ return box;
266
+ };
267
+ const sevCell = r => el('span', r.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(r.severity) ? r.severity : 'none'));
268
+ const columns = () => {
269
+ const cols = [];
270
+ if (s.writes) cols.push({ id: 'select', label: 'Select', sortable: false, hideable: false, cell: selectCell });
271
+ cols.push(
272
+ { id: 'severity', label: 'Severity', value: r => SEVERITIES.indexOf(r.severity), cell: sevCell },
273
+ { id: 'confidence', label: 'Confidence', value: r => r.confidence, cell: r => r.confidence || '—' },
274
+ { id: 'origin', label: 'Origin', value: r => r.origin, cell: originCell },
275
+ { id: 'task', label: 'Task', value: r => r.task, mono: true, cell: r => r.task || 'whole run' });
276
+ if (s.scope === 'repo') cols.push({ id: 'plan', label: 'Plan', value: r => r.plan, mono: true, cell: r => r.plan || '—' });
277
+ cols.push(
278
+ { id: 'file', label: 'File', value: r => r.file, mono: true, cell: r => r.file || '—' },
279
+ { id: 'summary', label: 'Summary', value: r => r.summary, cell: summaryCell },
280
+ { id: 'status', label: 'Status', value: r => STATUSES.indexOf(statusOf(r)), cell: statusCell });
281
+ return cols;
282
+ };
283
+ function drawTable() {
284
+ const rows = shown();
285
+ if (!rows.length) {
286
+ const total = s.rows.length;
287
+ tableHost.replaceChildren(el('p', total ? `No findings match these filters (${total} hidden).` : 'No findings. Nothing here needs fixing.', 'muted fnd-empty'));
288
+ syncActions();
289
+ return;
290
+ }
291
+ const id = 'findings-' + (s.scope === 'repo' ? 'repo' : 'run') + (s.writes ? '-w' : '');
292
+ tableHost.replaceChildren();
293
+ root.ForgeTable.render(tableHost, { id, label: 'Open findings', columns: columns(), rows, className: 'fnd-grid',
294
+ prefs: ctx.tablePrefs ? ctx.tablePrefs(id) : null, savePrefs: p => ctx.saveTablePrefs && ctx.saveTablePrefs(id, p) });
295
+ syncActions();
296
+ }
297
+
298
+ // loading: offline first (fast), then online for `filed`
299
+ const detailOf = res => res.detail || res.error || 'no answer';
300
+ async function load() {
301
+ const mine = ++s.seq;
302
+ syncScope();
303
+ setLamp('lamp-route', 'reading the findings…');
304
+ const q = { scope: s.scope, plausible: s.plausible, dismissed: s.dismissed };
305
+ const off = await UI.get(issuesUrl(key, { ...q, offline: true }));
306
+ if (mine !== s.seq) return;
307
+ if (!off.ok) {
308
+ s.rows = []; drawTable();
309
+ setLamp('st-fail', 'could not read the findings'); outcomeHost.replaceChildren(); UI.outcome(outcomeHost, off);
310
+ return;
311
+ }
312
+ s.rows = list(off.data.findings);
313
+ notes.replaceChildren(...list(off.data.notes).map(n => el('p', String(n))));
314
+ prune(); drawTable(); report();
315
+ setLamp('st-running', 'checking GitHub…');
316
+ const on = await UI.get(issuesUrl(key, { ...q, offline: false }));
317
+ if (mine !== s.seq) return;
318
+ if (!on.ok) { setLamp('st-fail', 'GitHub status unknown: ' + detailOf(on)); return; }
319
+ s.rows = mergeFiled(s.rows, list(on.data.findings));
320
+ notes.replaceChildren(...list(on.data.notes).map(n => el('p', String(n))));
321
+ prune(); drawTable(); report();
322
+ setLamp('st-pass', 'GitHub status checked');
323
+ }
324
+ function prune() {
325
+ const fps = new Set(s.rows.map(r => r.fp));
326
+ for (const fp of Array.from(s.selected)) if (!fps.has(fp)) s.selected.delete(fp);
327
+ }
328
+ function report() { if (ctx.onCount) ctx.onCount(s.rows.filter(r => statusOf(r) === 'open').length, s.scope); }
329
+
330
+ // dismiss: an optional reason, then the call
331
+ async function openDismiss() {
332
+ reasonHost.replaceChildren();
333
+ const sel = selection('dismiss', s.rows, s.selected);
334
+ if (!sel.enabled) return;
335
+ const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
336
+ input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
337
+ l.append(el('span', `Reason for dismissing ${sel.count}`, 'plate'), input);
338
+ const go = key_('Dismiss', 'ui-danger', () => { const reason = input.value; reasonHost.replaceChildren(); doDismiss(false, reason); });
339
+ const cancel = key_('Cancel', undefined, () => { reasonHost.replaceChildren(); dismissKey.focus(); });
340
+ input.addEventListener('keydown', e => {
341
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel.click(); }
342
+ else if (e.key === 'Enter') { e.preventDefault(); go.click(); }
343
+ });
344
+ reasonHost.append(l, go, cancel);
345
+ input.focus();
346
+ }
347
+ async function doDismiss(undo, reason) {
348
+ const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
349
+ if (!sel.enabled || s.busy) return;
350
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
351
+ const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
352
+ s.busy = false;
353
+ UI.outcome(outcomeHost, res, undo ? `restored ${sel.count}` : `dismissed ${sel.count}`);
354
+ if (res.ok) { for (const fp of sel.fps) s.selected.delete(fp); await load(); } else syncActions();
355
+ }
356
+
357
+ // filing: drafts for exactly the selection, a preview, then the POST
358
+ async function openFile() {
359
+ const sel = selection('file', s.rows, s.selected);
360
+ if (!sel.enabled || s.busy) return;
361
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
362
+ setLamp('st-running', 'preparing the issue drafts…');
363
+ const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
364
+ s.busy = false; syncActions();
365
+ if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
366
+ setLamp('st-pass', 'drafts ready');
367
+ preview(sel.fps, list(res.data.drafts));
368
+ }
369
+ function preview(fps, drafts) {
370
+ dlg.replaceChildren();
371
+ dlg.append(el('h2', 'File issues on GitHub'));
372
+ const warn = el('p', undefined, 'fnd-warn');
373
+ warn.append(el('i', undefined, 'lamp st-running'),
374
+ el('span', `This creates ${drafts.length} issue${drafts.length === 1 ? '' : 's'} on this run's GitHub repository, from your gh login, and cannot be undone from here.`));
375
+ dlg.append(warn);
376
+ if (!drafts.length) dlg.append(el('p', 'forge has no draft for this selection (its findings may be filed already).', 'muted'));
377
+ for (const d of drafts) {
378
+ const art = el('article', undefined, 'fnd-draft');
379
+ const h = el('h3', undefined, 'fnd-draft-title');
380
+ h.append(el('span', d.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(d.severity) ? d.severity : 'none')), el('span', d.title || 'untitled'));
381
+ art.append(h);
382
+ if (d.same_title) {
383
+ const sameUrl = safeUrl(d.same_title);
384
+ const p = el('p', undefined, 'fnd-same'); p.append(el('i', undefined, 'lamp fnd-amber'), el('span', 'An open issue with this title exists already: '));
385
+ if (sameUrl) { const a = el('a', sameUrl); a.href = sameUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', String(d.same_title)));
386
+ p.append(el('span', '. forge skips it.'));
387
+ art.append(p);
388
+ }
389
+ art.append(el('pre', d.body || '', 'fnd-body'));
390
+ dlg.append(art);
391
+ }
392
+ const lab = el('label', undefined, 'fnd-field'), labels = el('input');
393
+ labels.type = 'text'; labels.placeholder = 'bug, forge (optional, comma separated)';
394
+ lab.append(el('span', 'Labels', 'plate'), labels);
395
+ const labelNote = el('p', undefined, 'warn fnd-label-note'); labelNote.hidden = true;
396
+ const results = el('div', undefined, 'fnd-results');
397
+ const go = key_('File on GitHub', 'primary', async () => {
398
+ const parsed = parseLabels(labels.value);
399
+ labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
400
+ if (parsed.error) return;
401
+ go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
402
+ const res = await UI.post(issuesPath(key), fileBody(s.scope, fps, s.plausible, parsed.labels));
403
+ posting = false;
404
+ if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
405
+ const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
406
+ for (const ln of lines) {
407
+ const li = el('li', undefined, 'fnd-created-' + ln.kind), u = safeUrl(ln.url);
408
+ li.append(el('i', undefined, 'lamp ' + (ln.kind === 'created' ? 'st-pass' : 'fnd-amber')));
409
+ if (u) { const a = el('a', ln.text); a.href = u; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', ln.text));
410
+ ul.append(li);
411
+ }
412
+ results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
413
+ go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
414
+ for (const fp of fps) s.selected.delete(fp);
415
+ load();
416
+ });
417
+ const cancel = key_('Cancel', undefined, () => dlg.close());
418
+ go.disabled = !drafts.length;
419
+ const row = el('div', undefined, 'fnd-dialog-actions'); row.append(cancel, go);
420
+ dlg.append(lab, labelNote, results, row);
421
+ dlg.showModal(); (drafts.length ? go : cancel).focus();
422
+ }
423
+ dlg.addEventListener('click', e => { if (e.target === dlg && !posting) dlg.close(); });
424
+ dlg.addEventListener('cancel', e => { if (posting) e.preventDefault(); });
425
+ dlg.addEventListener('close', () => { fileKey.focus(); });
426
+
427
+ syncScope();
428
+ UI.controlsEnabled().then(on => {
429
+ s.writes = !!on; readOnly.hidden = on; syncActions();
430
+ if (s.rows.length) drawTable();
431
+ });
432
+ load();
433
+ return { reload: load };
434
+ }
435
+
436
+ return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
437
+ parseLabels, resultLines, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
438
+ });
@@ -201,8 +201,9 @@
201
201
  if (drawn[t.id]) return;
202
202
  drawn[t.id] = true;
203
203
  var p = pos[t.id];
204
- var status = str(t.status, 'pending');
205
- var stClass = 'st-' + status.toLowerCase().replace(/[^a-z0-9_-]/g, '');
204
+ var status = window.ForgeRunModel.taskStatus(t);
205
+ var verdict = window.ForgeRunModel.taskVerdict(t);
206
+ var stClass = 'st-' + String(status || 'pending').toLowerCase().replace(/[^a-z0-9_-]/g, '');
206
207
  var selected = options.selected === t.id;
207
208
  var id = String(t.id);
208
209
 
@@ -217,7 +218,7 @@
217
218
 
218
219
  var tip = [str(t.title, id), 'status: ' + status,
219
220
  'dwarf: ' + str(t.dwarf, '—') + ' / qa: ' + str(t.qa, '—')];
220
- if (t.verdict) tip.push('verdict: ' + t.verdict);
221
+ if (verdict.text) tip.push('verdict: ' + verdict.text);
221
222
  if (t.explored) tip.push('explored');
222
223
  var title = el('title', null, g);
223
224
  title.textContent = tip.join('\n');
@@ -238,8 +239,8 @@
238
239
  x: 12, y: 36, 'font-size': 12, fill: 'currentColor', 'fill-opacity': 0.75
239
240
  });
240
241
 
241
- if (t.verdict === 'PASS' || t.verdict === 'FAIL') {
242
- var pass = t.verdict === 'PASS';
242
+ if (verdict.mark === 'PASS' || verdict.mark === 'FAIL') {
243
+ var pass = verdict.mark === 'PASS';
243
244
  // a drawn tick or cross, in the verdict's colour
244
245
  el('path', { 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), d: pass ? 'M' + (NODE_W - 20) + ' 12l4 4 7-8' : 'M' + (NODE_W - 19) + ' 8l8 8m0-8l-8 8',
245
246
  fill: 'none', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
@@ -17,12 +17,14 @@
17
17
  <link rel="stylesheet" href="style.css">
18
18
  <script src="theme.js"></script>
19
19
  <script src="icons.js" defer></script>
20
+ <script src="uikit.js" defer></script>
20
21
  <script src="datatable.js" defer></script>
21
22
  <script src="runmodel.js" defer></script>
22
23
  <script src="cloudmodel.js" defer></script>
23
24
  <script src="modelstats.js" defer></script>
24
25
  <script src="graph.js" defer></script>
25
26
  <script src="gantt.js" defer></script>
27
+ <script src="controls.js" defer></script>
26
28
  <script src="panel.js" defer></script>
27
29
  <script src="diffview.js" defer></script>
28
30
  <script src="convo.js" defer></script>
@@ -30,6 +32,9 @@
30
32
  <script src="map3d.js" defer></script>
31
33
  <script src="cloudui.js" defer></script>
32
34
  <script src="leaderboard.js" defer></script>
35
+ <script src="findingsview.js" defer></script>
36
+ <script src="rundelete.js" defer></script>
37
+ <script src="settings.js" defer></script>
33
38
  <script src="search.js" defer></script>
34
39
  <script src="notify.js" defer></script>
35
40
  <script src="app.js" defer></script>
@@ -37,11 +42,12 @@
37
42
  <body data-shell="immersive" data-runs="open" data-insp="open" data-tasks="open">
38
43
  <script src="shell.js"></script>
39
44
  <header class="topbar">
40
- <a class="brand" href="#/" title="forge-ui · local run viewer, read only"><span class="brand-plate">forge</span><span class="brand-sub">ui</span></a>
45
+ <a class="brand" href="#/" title="forge-ui · local run viewer"><span class="brand-plate">forge</span><span class="brand-sub">ui</span></a>
41
46
  <nav class="desk-nav" aria-label="Views">
42
47
  <a class="top-link" href="#/">Map</a>
43
48
  <a class="top-link" href="#/cloud">Cloud</a>
44
49
  <a class="top-link" href="#/models">Models</a>
50
+ <a class="top-link" href="#/settings">Settings</a>
45
51
  </nav>
46
52
  <button class="top-search" type="button" data-jump title="Jump to a run, task, project, model or command (/)"><span class="top-search-label"><span>Search</span></span> <kbd>Ctrl K</kbd></button>
47
53
  <div class="desk-tools">
@@ -149,6 +149,7 @@ window.ForgeLeaderboard = (() => {
149
149
  ['Verdicts', '', 'PASS and FAIL verdicts over every review'],
150
150
  ...MS.SEVERITIES.map(s => [s, 'lb-num', SEV_TITLE(s)]),
151
151
  ['Shadow agreement', 'lb-num', 'How often this model\'s shadow verdict matched the primary reviewer\'s (both PASS or FAIL)'],
152
+ ['Did not hold', 'lb-num', 'Of the work this model PASSed that was integrated, the share later reverted or with under 80% of its lines kept (forge dataset reviewers, every run in the dataset: the filters do not apply). Advisory: routing never reads it'],
152
153
  ['Median time', 'lb-num', 'Median duration of a review'],
153
154
  ['Tokens in / out', 'lb-num', 'Summed over the reviews that recorded tokens'],
154
155
  ];
@@ -182,7 +183,23 @@ window.ForgeLeaderboard = (() => {
182
183
  td.title = `${a.k} of ${a.n} shadow verdicts matched the primary's; 95% Wilson interval ${pct(a.lo)}–${pct(a.hi)}${a.unknown ? ` · ${a.unknown} without two comparable verdicts` : ''}`;
183
184
  return td;
184
185
  }
186
+ function heldCell(h) {
187
+ const days = h.horizon ? ` ${h.horizon} days after integrate` : '';
188
+ if (h.state === 'unavailable') return numCell('—', 'The run dataset could not be read (forge dataset reviewers)');
189
+ if (h.state === 'none') return numCell('—', `No work this model PASSed has a survival outcome${days} yet (forge dataset outcomes records them)`);
190
+ const detail = `${h.reverted} reverted, ${h.rewritten} rewritten, of ${h.n} PASSed and integrated${days}`;
191
+ if (h.state === 'few') {
192
+ const td = numCell('not enough data', `${detail}; a rate needs ${h.need || 'more'} outcomes`);
193
+ td.classList.add('muted');
194
+ return td;
195
+ }
196
+ const td = el('td', undefined, 'lb-num');
197
+ td.append(el('strong', pct(h.p)), document.createTextNode(' '), el('span', `${h.k}/${h.n}`, 'muted'));
198
+ td.title = `${detail}; 95% Wilson interval ${pct(h.lo)}–${pct(h.hi)}`;
199
+ return td;
200
+ }
185
201
  function reviewerTable(rows, ctx) {
202
+ const held = r => MS.heldUp(ctx.reviewerOutcomes, r.alias);
186
203
  const columns = colsOf(REV_COLS, [
187
204
  { id: 'model', value: r => r.alias, cell: r => modelCell(r.alias, ctx, '') },
188
205
  { id: 'primary', value: r => r.reviews.primary, cell: r => numCell(r.reviews.primary) },
@@ -191,6 +208,7 @@ window.ForgeLeaderboard = (() => {
191
208
  { id: 'verdicts', value: r => { const v = r.verdicts, t = v.PASS + v.FAIL + v.other; return t ? v.PASS / t : null; }, cell: r => verdictCell(r.verdicts) },
192
209
  ...MS.SEVERITIES.map(sv => ({ id: 'sev-' + sv, value: r => r.findings[sv], cell: r => findingCell(r, sv) })),
193
210
  { id: 'agreement', value: r => r.agreement.n ? r.agreement.p : null, cell: r => agreementCell(r.agreement) },
211
+ { id: 'held', value: r => { const h = held(r); return h.state === 'rate' ? h.p : null; }, cell: r => heldCell(held(r)) },
194
212
  { id: 'time', value: r => num(r.medianDurationS), cell: r => numCell(dur(r.medianDurationS)) },
195
213
  { id: 'tokens', value: r => (r.tokens.attempts ?? r.tokens.reviews) ? (r.tokens.input || 0) + (r.tokens.output || 0) : null, cell: r => tokenCell(r.tokens, r.reviews.total, 'review') },
196
214
  ]);
@@ -245,5 +245,23 @@
245
245
  return { implementers: imp.rows, reviewers: rev.rows, byDifficulty, totals, caveats, filter: f };
246
246
  }
247
247
 
248
- return { DIFFS, KINDS, SEVERITIES, DEFAULT_MIN_N, wilson, emptyFilter, normFilter, taskMatch, options, leaderboard };
248
+ // How the work a reviewer PASSed held up, from GET /api/models/reviewers (`forge dataset reviewers --json`):
249
+ // { state: 'unavailable' } without a report, 'none' when the alias has no landed outcome, 'few' below the
250
+ // report's minimum (no rate is shown then), else 'rate' with the share that did not hold and its interval.
251
+ function heldUp(report, alias) {
252
+ const r = obj(report);
253
+ if (!r || !Array.isArray(r.reviewers)) return { state: 'unavailable' };
254
+ const horizon = num(r.horizon_days), row = r.reviewers.find(x => obj(x) && x.reviewer === alias);
255
+ if (!row) return { state: 'none', horizon };
256
+ const n = num(row.landed), k = num(row.did_not_hold);
257
+ if (n === null || k === null) return { state: 'unavailable' };
258
+ const base = { n, k, reverted: num(row.reverted) || 0, rewritten: num(row.rewritten) || 0, horizon };
259
+ const ci = list(row.ci);
260
+ if (row.enough !== true || num(row.rate) === null || num(ci[0]) === null || num(ci[1]) === null) {
261
+ return { state: 'few', need: num(r.min_landed), ...base };
262
+ }
263
+ return { state: 'rate', p: row.rate, lo: ci[0], hi: ci[1], ...base };
264
+ }
265
+
266
+ return { DIFFS, KINDS, SEVERITIES, DEFAULT_MIN_N, wilson, emptyFilter, normFilter, taskMatch, options, leaderboard, heldUp };
249
267
  });