@giamme/forge 0.9.0 → 0.10.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 (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. package/ui/forge_ui/static/uikit.js +161 -0
@@ -0,0 +1,412 @@
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', '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
+
110
+ /* ---------- DOM ---------- */
111
+ function render(host, ctx) {
112
+ const UI = root.ForgeUI;
113
+ const el = (tag, text, cls) => UI.el(tag, text, cls);
114
+ const s = { scope: 'run', plausible: false, dismissed: false, filter: { status: 'open', severity: 'all', origin: 'all' },
115
+ rows: [], selected: new Set(), seq: 0, writes: false, busy: false };
116
+ const key = ctx.key;
117
+
118
+ const head = el('div', undefined, 'fnd-head');
119
+ const title = el('h1', 'Open findings', 'module-title');
120
+ const back = el('a', 'Back to the run', 'fnd-back'); back.href = ctx.back || '#/';
121
+ head.append(title, back);
122
+ const lede = el('p', undefined, 'muted fnd-lede');
123
+ const scopeBar = el('div', undefined, 'seg fnd-scope'); scopeBar.setAttribute('role', 'group'); scopeBar.setAttribute('aria-label', 'Scope');
124
+ const scopeKeys = {};
125
+ for (const [v, label] of [['run', 'This run'], ['repo', 'All runs of this repo']]) {
126
+ const b = el('button', label); b.type = 'button';
127
+ b.addEventListener('click', () => { if (s.scope === v) return; s.scope = v; s.selected.clear(); load(); });
128
+ scopeKeys[v] = b; scopeBar.append(b);
129
+ }
130
+ const lamp = el('p', undefined, 'fnd-lamp'); lamp.setAttribute('role', 'status');
131
+ const notes = el('div', undefined, 'fnd-notes muted');
132
+
133
+ // filters
134
+ const filters = el('div', undefined, 'fnd-filters');
135
+ const select = (label, options, get, set) => {
136
+ const l = el('label', undefined, 'fnd-field'), sel = el('select');
137
+ for (const [v, t] of options) { const o = el('option', t); o.value = v; sel.append(o); }
138
+ sel.value = get();
139
+ sel.addEventListener('change', () => { set(sel.value); drawTable(); });
140
+ l.append(el('span', label, 'plate'), sel);
141
+ return l;
142
+ };
143
+ const toggle = (label, get, set) => {
144
+ const l = el('label', undefined, 'fnd-toggle'), c = el('input');
145
+ c.type = 'checkbox'; c.checked = get();
146
+ c.addEventListener('change', () => { set(c.checked); load(); });
147
+ l.append(c, el('span', label));
148
+ return l;
149
+ };
150
+ filters.append(
151
+ select('Status', [['open', 'open'], ['all', 'all'], ...STATUSES.slice(1).map(x => [x, x])], () => s.filter.status, v => { s.filter.status = v; }),
152
+ select('Severity', [['all', 'all'], ...SEVERITIES.map(x => [x, x])], () => s.filter.severity, v => { s.filter.severity = v; }),
153
+ select('Origin', [['all', 'all'], ...ORIGINS.map(x => [x, x])], () => s.filter.origin, v => { s.filter.origin = v; }),
154
+ toggle('Include plausible', () => s.plausible, v => { s.plausible = v; }),
155
+ toggle('Show dismissed', () => s.dismissed, v => { s.dismissed = v; }));
156
+
157
+ // actions (only with controls)
158
+ const actions = el('div', undefined, 'fnd-actions'); actions.hidden = true;
159
+ actions.setAttribute('role', 'group'); actions.setAttribute('aria-label', 'Actions on the selected findings');
160
+ const key_ = (text, cls, fn) => { const b = el('button', text, cls); b.type = 'button'; b.addEventListener('click', fn); return b; };
161
+ const fileKey = key_('File issues', 'primary', () => openFile());
162
+ const dismissKey = key_('Dismiss', undefined, () => openDismiss());
163
+ const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
164
+ const allKey = key_('Select shown open', undefined, () => {
165
+ for (const r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
166
+ drawTable(); syncActions();
167
+ });
168
+ const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
169
+ const actionNote = el('span', undefined, 'fnd-note muted'); actionNote.setAttribute('aria-live', 'polite');
170
+ actions.append(allKey, noneKey, fileKey, dismissKey, undismissKey, actionNote);
171
+ const reasonHost = el('div', undefined, 'fnd-reason');
172
+ const outcomeHost = el('div', undefined, 'fnd-outcome');
173
+ const tableHost = el('div', undefined, 'fnd-table');
174
+ const readOnly = el('p', 'read-only: findings can be read here, not filed or dismissed.', 'muted fnd-readonly'); readOnly.hidden = true;
175
+
176
+ host.replaceChildren(head, lede, scopeBar, lamp, notes, filters, readOnly, actions, reasonHost, outcomeHost, tableHost);
177
+ let posting = false; // a filing POST is running: the dialog stays open until its results are shown
178
+ const dlg = el('dialog', undefined, 'fnd-dialog'); dlg.setAttribute('aria-label', 'File issues on GitHub');
179
+ host.append(dlg);
180
+
181
+ const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
182
+ function syncScope() {
183
+ for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
184
+ lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
185
+ : 'Findings every run of this repository left unfixed.';
186
+ }
187
+ function syncActions() {
188
+ actions.hidden = !s.writes;
189
+ if (!s.writes) return;
190
+ const f = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
191
+ fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
192
+ fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
193
+ undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
194
+ actionNote.textContent = f.note || d.note || u.note || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
195
+ }
196
+ function setLamp(cls, text) {
197
+ lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
198
+ }
199
+
200
+ // cells
201
+ const statusCell = r => {
202
+ const st = statusOf(r), box = el('span', undefined, 'fnd-status fnd-' + st);
203
+ box.append(el('i', undefined, 'lamp ' + (st === 'filed' ? 'fnd-amber' : st === 'fixed' ? 'st-pass' : 'lamp-off')));
204
+ if (st === 'dismissed') { box.title = r.dismissed.reason ? String(r.dismissed.reason) : 'dismissed'; box.append(el('span', 'dismissed')); return box; }
205
+ if (st === 'open') { box.append(el('span', 'open')); return box; }
206
+ const url = safeUrl(r.filed && r.filed.url), word = st === 'filed' ? 'filed' : 'fixed';
207
+ 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); }
208
+ else box.append(el('span', word));
209
+ return box;
210
+ };
211
+ const selectCell = r => {
212
+ const c = el('input'); c.type = 'checkbox';
213
+ c.setAttribute('aria-label', 'Select finding: ' + String(r.summary || r.fp).slice(0, 80));
214
+ c.checked = s.selected.has(r.fp);
215
+ c.disabled = !selectable('file', r) && !selectable('undismiss', r);
216
+ c.addEventListener('change', () => { if (c.checked) s.selected.add(r.fp); else s.selected.delete(r.fp); syncActions(); });
217
+ return c;
218
+ };
219
+ const summaryCell = r => {
220
+ const box = el('div', undefined, 'fnd-summary'), line = el('div', undefined, 'fnd-summary-line');
221
+ const more = el('button', undefined, 'fnd-more'); more.type = 'button';
222
+ more.setAttribute('aria-expanded', 'false'); more.setAttribute('aria-label', 'Show the full text');
223
+ more.append(el('span', '▸', 'fnd-caret'));
224
+ const detail = el('div', undefined, 'fnd-detail'); detail.hidden = true;
225
+ detail.append(el('pre', r.text || r.summary || '', 'fnd-text'));
226
+ const meta = el('dl', undefined, 'fnd-meta');
227
+ for (const [k, v] of [['Reviewer', r.reviewer], ['Commit', r.commit], ['Fingerprint', r.fp], ['Plan', r.plan_dir || r.plan],
228
+ ['Dismissed', r.dismissed ? `${r.dismissed.at || ''} ${r.dismissed.reason || ''}`.trim() : null]]) {
229
+ if (v) meta.append(el('dt', k), el('dd', v));
230
+ }
231
+ detail.append(meta);
232
+ more.addEventListener('click', () => {
233
+ const open = detail.hidden; detail.hidden = !open;
234
+ more.setAttribute('aria-expanded', String(open)); more.setAttribute('aria-label', open ? 'Hide the full text' : 'Show the full text');
235
+ more.firstChild.textContent = open ? '▾' : '▸';
236
+ });
237
+ line.append(more, el('span', r.summary || r.text || '—'));
238
+ box.append(line, detail);
239
+ return box;
240
+ };
241
+ const sevCell = r => el('span', r.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(r.severity) ? r.severity : 'none'));
242
+ const columns = () => {
243
+ const cols = [];
244
+ if (s.writes) cols.push({ id: 'select', label: 'Select', sortable: false, hideable: false, cell: selectCell });
245
+ cols.push(
246
+ { id: 'severity', label: 'Severity', value: r => SEVERITIES.indexOf(r.severity), cell: sevCell },
247
+ { id: 'confidence', label: 'Confidence', value: r => r.confidence, cell: r => r.confidence || '—' },
248
+ { id: 'origin', label: 'Origin', value: r => r.origin, cell: r => r.origin || '—' },
249
+ { id: 'task', label: 'Task', value: r => r.task, mono: true, cell: r => r.task || 'whole run' });
250
+ if (s.scope === 'repo') cols.push({ id: 'plan', label: 'Plan', value: r => r.plan, mono: true, cell: r => r.plan || '—' });
251
+ cols.push(
252
+ { id: 'file', label: 'File', value: r => r.file, mono: true, cell: r => r.file || '—' },
253
+ { id: 'summary', label: 'Summary', value: r => r.summary, cell: summaryCell },
254
+ { id: 'status', label: 'Status', value: r => STATUSES.indexOf(statusOf(r)), cell: statusCell });
255
+ return cols;
256
+ };
257
+ function drawTable() {
258
+ const rows = shown();
259
+ if (!rows.length) {
260
+ const total = s.rows.length;
261
+ tableHost.replaceChildren(el('p', total ? `No findings match these filters (${total} hidden).` : 'No findings. Nothing here needs fixing.', 'muted fnd-empty'));
262
+ syncActions();
263
+ return;
264
+ }
265
+ const id = 'findings-' + (s.scope === 'repo' ? 'repo' : 'run') + (s.writes ? '-w' : '');
266
+ tableHost.replaceChildren();
267
+ root.ForgeTable.render(tableHost, { id, label: 'Open findings', columns: columns(), rows, className: 'fnd-grid',
268
+ prefs: ctx.tablePrefs ? ctx.tablePrefs(id) : null, savePrefs: p => ctx.saveTablePrefs && ctx.saveTablePrefs(id, p) });
269
+ syncActions();
270
+ }
271
+
272
+ // loading: offline first (fast), then online for `filed`
273
+ const detailOf = res => res.detail || res.error || 'no answer';
274
+ async function load() {
275
+ const mine = ++s.seq;
276
+ syncScope();
277
+ setLamp('lamp-route', 'reading the findings…');
278
+ const q = { scope: s.scope, plausible: s.plausible, dismissed: s.dismissed };
279
+ const off = await UI.get(issuesUrl(key, { ...q, offline: true }));
280
+ if (mine !== s.seq) return;
281
+ if (!off.ok) {
282
+ s.rows = []; drawTable();
283
+ setLamp('st-fail', 'could not read the findings'); outcomeHost.replaceChildren(); UI.outcome(outcomeHost, off);
284
+ return;
285
+ }
286
+ s.rows = list(off.data.findings);
287
+ notes.replaceChildren(...list(off.data.notes).map(n => el('p', String(n))));
288
+ prune(); drawTable(); report();
289
+ setLamp('st-running', 'checking GitHub…');
290
+ const on = await UI.get(issuesUrl(key, { ...q, offline: false }));
291
+ if (mine !== s.seq) return;
292
+ if (!on.ok) { setLamp('st-fail', 'GitHub status unknown: ' + detailOf(on)); return; }
293
+ s.rows = mergeFiled(s.rows, list(on.data.findings));
294
+ notes.replaceChildren(...list(on.data.notes).map(n => el('p', String(n))));
295
+ prune(); drawTable(); report();
296
+ setLamp('st-pass', 'GitHub status checked');
297
+ }
298
+ function prune() {
299
+ const fps = new Set(s.rows.map(r => r.fp));
300
+ for (const fp of Array.from(s.selected)) if (!fps.has(fp)) s.selected.delete(fp);
301
+ }
302
+ function report() { if (ctx.onCount) ctx.onCount(s.rows.filter(r => statusOf(r) === 'open').length, s.scope); }
303
+
304
+ // dismiss: an optional reason, then the call
305
+ async function openDismiss() {
306
+ reasonHost.replaceChildren();
307
+ const sel = selection('dismiss', s.rows, s.selected);
308
+ if (!sel.enabled) return;
309
+ const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
310
+ input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
311
+ l.append(el('span', `Reason for dismissing ${sel.count}`, 'plate'), input);
312
+ const go = key_('Dismiss', 'ui-danger', () => { const reason = input.value; reasonHost.replaceChildren(); doDismiss(false, reason); });
313
+ const cancel = key_('Cancel', undefined, () => { reasonHost.replaceChildren(); dismissKey.focus(); });
314
+ input.addEventListener('keydown', e => {
315
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel.click(); }
316
+ else if (e.key === 'Enter') { e.preventDefault(); go.click(); }
317
+ });
318
+ reasonHost.append(l, go, cancel);
319
+ input.focus();
320
+ }
321
+ async function doDismiss(undo, reason) {
322
+ const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
323
+ if (!sel.enabled || s.busy) return;
324
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
325
+ const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
326
+ s.busy = false;
327
+ UI.outcome(outcomeHost, res, undo ? `restored ${sel.count}` : `dismissed ${sel.count}`);
328
+ if (res.ok) { for (const fp of sel.fps) s.selected.delete(fp); await load(); } else syncActions();
329
+ }
330
+
331
+ // filing: drafts for exactly the selection, a preview, then the POST
332
+ async function openFile() {
333
+ const sel = selection('file', s.rows, s.selected);
334
+ if (!sel.enabled || s.busy) return;
335
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
336
+ setLamp('st-running', 'preparing the issue drafts…');
337
+ const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
338
+ s.busy = false; syncActions();
339
+ if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
340
+ setLamp('st-pass', 'drafts ready');
341
+ preview(sel.fps, list(res.data.drafts));
342
+ }
343
+ function preview(fps, drafts) {
344
+ dlg.replaceChildren();
345
+ dlg.append(el('h2', 'File issues on GitHub'));
346
+ const warn = el('p', undefined, 'fnd-warn');
347
+ warn.append(el('i', undefined, 'lamp st-running'),
348
+ 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.`));
349
+ dlg.append(warn);
350
+ if (!drafts.length) dlg.append(el('p', 'forge has no draft for this selection (its findings may be filed already).', 'muted'));
351
+ for (const d of drafts) {
352
+ const art = el('article', undefined, 'fnd-draft');
353
+ const h = el('h3', undefined, 'fnd-draft-title');
354
+ h.append(el('span', d.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(d.severity) ? d.severity : 'none')), el('span', d.title || 'untitled'));
355
+ art.append(h);
356
+ if (d.same_title) {
357
+ const sameUrl = safeUrl(d.same_title);
358
+ 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: '));
359
+ 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)));
360
+ p.append(el('span', '. forge skips it.'));
361
+ art.append(p);
362
+ }
363
+ art.append(el('pre', d.body || '', 'fnd-body'));
364
+ dlg.append(art);
365
+ }
366
+ const lab = el('label', undefined, 'fnd-field'), labels = el('input');
367
+ labels.type = 'text'; labels.placeholder = 'bug, forge (optional, comma separated)';
368
+ lab.append(el('span', 'Labels', 'plate'), labels);
369
+ const labelNote = el('p', undefined, 'warn fnd-label-note'); labelNote.hidden = true;
370
+ const results = el('div', undefined, 'fnd-results');
371
+ const go = key_('File on GitHub', 'primary', async () => {
372
+ const parsed = parseLabels(labels.value);
373
+ labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
374
+ if (parsed.error) return;
375
+ go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
376
+ const res = await UI.post(issuesPath(key), fileBody(s.scope, fps, s.plausible, parsed.labels));
377
+ posting = false;
378
+ if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
379
+ const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
380
+ for (const ln of lines) {
381
+ const li = el('li', undefined, 'fnd-created-' + ln.kind), u = safeUrl(ln.url);
382
+ li.append(el('i', undefined, 'lamp ' + (ln.kind === 'created' ? 'st-pass' : 'fnd-amber')));
383
+ 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));
384
+ ul.append(li);
385
+ }
386
+ results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
387
+ go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
388
+ for (const fp of fps) s.selected.delete(fp);
389
+ load();
390
+ });
391
+ const cancel = key_('Cancel', undefined, () => dlg.close());
392
+ go.disabled = !drafts.length;
393
+ const row = el('div', undefined, 'fnd-dialog-actions'); row.append(cancel, go);
394
+ dlg.append(lab, labelNote, results, row);
395
+ dlg.showModal(); (drafts.length ? go : cancel).focus();
396
+ }
397
+ dlg.addEventListener('click', e => { if (e.target === dlg && !posting) dlg.close(); });
398
+ dlg.addEventListener('cancel', e => { if (posting) e.preventDefault(); });
399
+ dlg.addEventListener('close', () => { fileKey.focus(); });
400
+
401
+ syncScope();
402
+ UI.controlsEnabled().then(on => {
403
+ s.writes = !!on; readOnly.hidden = on; syncActions();
404
+ if (s.rows.length) drawTable();
405
+ });
406
+ load();
407
+ return { reload: load };
408
+ }
409
+
410
+ return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
411
+ parseLabels, resultLines, safeUrl, MAX_FPS };
412
+ });
@@ -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">
@@ -1,6 +1,7 @@
1
1
  /* forge-ui inspector: what a selected node is, which model owned it, what prompt launched it, how long it
2
2
  took and what its reviewer found. Defines window.ForgePanel.render(host, ctx). All API values enter the
3
- DOM as text. Large texts (prompts, reports) come from the lazy endpoints and are fetched once per node. */
3
+ DOM as text. Large texts (prompts, reports) come from the lazy endpoints and are fetched once per node.
4
+ The run's and a task's write controls are controls.js's (ForgeControls); this file only says where they stand. */
4
5
  window.ForgePanel = (() => {
5
6
  'use strict';
6
7
  const RM = window.ForgeRunModel;
@@ -59,6 +60,15 @@ window.ForgePanel = (() => {
59
60
  return a;
60
61
  }
61
62
  function muted(parent, text) { parent.append(el('p', text, 'muted')); }
63
+ // who decided an escalation: policy, a human's retry --dwarf (retry) or a pin; older rows read as policy
64
+ const sourcePlate = source => el('span', source || 'policy', 'plate src-plate src-' + String(source || 'policy').replace(/[^a-z]/g, ''));
65
+ // the write controls of a plan run (task undefined) or of one of its tasks; a page without controls.js says it is read-only
66
+ function controlsSection(host, ctx, task) {
67
+ if (ctx.run.kind === 'solo') return;
68
+ if (window.ForgeControls && window.ForgeUI && window.ForgeUI.confirmInline) { window.ForgeControls[task ? 'mountTask' : 'mountRun'](host, ctx, task); return; }
69
+ const s = section(host, 'Controls');
70
+ muted(s, 'Read-only: this page cannot change the run.');
71
+ }
62
72
  // a link into the diff viewer (ctx.diffHash is absent where there is no viewer)
63
73
  function diffLink(ctx, text, spec, cls = 'diff-link') { if (!ctx.diffHash) return null; const a = el('a', text, cls); a.href = ctx.diffHash(spec); return a; }
64
74
  // a lazy resource: returns cached data, or starts the fetch and re-renders when it lands
@@ -216,6 +226,7 @@ window.ForgePanel = (() => {
216
226
  if (F.final.findings) findingCounts(f, F.final.findings);
217
227
  }
218
228
  if (list(run.deferrals).length) details(host, `Deferred tasks (${run.deferrals.length})`, run.deferrals.join('\n'), 'deferrals');
229
+ controlsSection(host, ctx);
219
230
  runRecord(host, ctx);
220
231
  muted(host, 'Select a task, an attempt or a finish node on the map or in the table to see its prompts, models, timings and findings.');
221
232
  }
@@ -239,7 +250,11 @@ window.ForgePanel = (() => {
239
250
  fields(ms, [['Dwarf', plate(task.dwarf)], ['Reviewer', plate(task.qa)], ['Attempt', task.attempt]]);
240
251
  const route = list(task.route).slice(-1)[0];
241
252
  if (route) fields(ms, [['Routed', `${display(route.chosen)} from ${list(route.candidates).join(', ') || '—'}${route.explored ? ` (exploratory, p=${display(route.propensity)})` : ''}`, true], ['Tier · ε', `${display(route.tier)} · ${display(route.epsilon)}`]]);
242
- for (const e of list(task.escalations)) muted(ms, `Escalated after attempt ${display(e.attempt)}: ${display(e.from)} → ${display(e.to)} at ${time(e.at)}`);
253
+ for (const e of list(task.escalations)) {
254
+ const line = el('p', `Escalated after attempt ${display(e.attempt)}: ${display(e.from)} → ${display(e.to)} at ${time(e.at)} `, 'muted');
255
+ line.append(sourcePlate(e.source)); ms.append(line);
256
+ }
257
+ controlsSection(host, ctx, task);
243
258
 
244
259
  // timings
245
260
  const tt = RM.taskTimes(task, run, now);
@@ -250,7 +265,7 @@ window.ForgePanel = (() => {
250
265
 
251
266
  // attempts, each with its dwarf and its review
252
267
  const as = section(host, 'Attempts');
253
- const tp = RM.taskParts(task);
268
+ const tp = RM.taskParts(task), usedEscalations = new Set();
254
269
  if (!list(task.executions).length) muted(as, 'No executions recorded yet' + (task.status === 'PENDING' ? ': this task has not started.' : '.'));
255
270
  for (const z of tp.zones) {
256
271
  const box = el('div', undefined, 'attempt');
@@ -275,7 +290,12 @@ window.ForgePanel = (() => {
275
290
  if (go.childElementCount) line.append(go);
276
291
  box.append(line);
277
292
  }
278
- for (const l of tp.links.filter(l => l.kind === 'escalation' && z.parts.includes(l.to))) box.append(el('p', `escalated: ${l.label}`, 'muted'));
293
+ for (const l of tp.links.filter(l => l.kind === 'escalation' && z.parts.includes(l.to))) {
294
+ const line = el('p', `escalated: ${l.label} `, 'muted');
295
+ const e = list(task.escalations).find(x => !usedEscalations.has(x) && `${x.from || '?'} → ${x.to || '?'}` === l.label); // the link carries only its label
296
+ if (e) { usedEscalations.add(e); line.append(sourcePlate(e.source)); }
297
+ box.append(line);
298
+ }
279
299
  as.append(box);
280
300
  }
281
301