@giamme/forge 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +13 -1
  5. package/docs/commands.md +11 -5
  6. package/docs/dataset.md +13 -0
  7. package/docs/decomposed-runs.md +13 -3
  8. package/docs/forge-ui.md +8 -3
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +73 -2
  11. package/forge +16 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +86 -4
  14. package/references/decompose.md +24 -6
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +4 -1
  17. package/scripts/forge-config.py +5 -2
  18. package/scripts/forge-control.py +4 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +345 -17
  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 +132 -14
  24. package/scripts/forge-parallel.sh +96 -11
  25. package/scripts/forge-prompt.py +61 -4
  26. package/scripts/forge-route.py +72 -12
  27. package/scripts/forge-runtime.py +47 -1
  28. package/scripts/forge-ui.py +8 -4
  29. package/scripts/forge_dataset/__init__.py +7 -2
  30. package/scripts/forge_dataset/cli.py +44 -1
  31. package/scripts/forge_dataset/export.py +36 -3
  32. package/scripts/forge_dataset/forecast.py +355 -0
  33. package/scripts/forge_dataset/harvest.py +23 -2
  34. package/scripts/forge_dataset/importer.py +20 -8
  35. package/scripts/forge_dataset/reviewers.py +145 -0
  36. package/scripts/forge_dataset/routing.py +1 -0
  37. package/scripts/forge_jev/__init__.py +7 -3
  38. package/scripts/forge_registry.py +15 -10
  39. package/ui/README.md +5 -4
  40. package/ui/docs/api.md +88 -23
  41. package/ui/forge_ui/control.py +26 -5
  42. package/ui/forge_ui/reader.py +184 -11
  43. package/ui/forge_ui/static/app.js +24 -10
  44. package/ui/forge_ui/static/cloud3d.js +4 -1
  45. package/ui/forge_ui/static/cloudmodel.js +1 -1
  46. package/ui/forge_ui/static/cloudui.js +13 -4
  47. package/ui/forge_ui/static/findingsview.js +65 -14
  48. package/ui/forge_ui/static/graph.js +6 -5
  49. package/ui/forge_ui/static/leaderboard.js +18 -0
  50. package/ui/forge_ui/static/modelstats.js +19 -1
  51. package/ui/forge_ui/static/panel.js +52 -10
  52. package/ui/forge_ui/static/runmodel.js +128 -13
  53. package/ui/forge_ui/static/scene3d.js +32 -15
  54. package/ui/forge_ui/static/settings.js +48 -12
  55. package/ui/forge_ui/static/style.css +7 -3
@@ -16,7 +16,7 @@
16
16
  const MAX_FPS = 25, MAX_LABELS = 5, MAX_LABEL = 50, MAX_REASON = 500;
17
17
  const list = v => Array.isArray(v) ? v : [];
18
18
  const SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
19
- const ORIGINS = ['tolerated', 'review', 'plausible'];
19
+ const ORIGINS = ['tolerated', 'accepted', 'review', 'plausible'];
20
20
  const STATUSES = ['open', 'filed', 'fixed', 'dismissed'];
21
21
 
22
22
  /* ---------- pure ---------- */
@@ -64,6 +64,20 @@
64
64
  const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
65
65
  return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
66
66
  }
67
+ // Fingerprints "Select shown open" adds. `rows` are the rows the filters leave shown. The cap is how many of
68
+ // those are already selected and open — the same rows File and Dismiss take — so a selection a filter hides stays
69
+ // selected and does not count. A shown row that is not open is not added.
70
+ function selectShownOpen(rows, selected) {
71
+ const chosen = new Set(selected instanceof Set || Array.isArray(selected) ? selected : []);
72
+ const add = [];
73
+ for (const r of list(rows)) {
74
+ if (!selectable('file', r) || chosen.has(r.fp)) continue;
75
+ if (selection('file', rows, chosen).count >= MAX_FPS) break;
76
+ chosen.add(r.fp);
77
+ add.push(r.fp);
78
+ }
79
+ return add;
80
+ }
67
81
  const bit = v => v ? '1' : '0';
68
82
  // The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
69
83
  function issuesUrl(key, o) {
@@ -104,8 +118,27 @@
104
118
  return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
105
119
  });
106
120
  }
121
+ // The CLI's `notes` as lines for the dialog, only when the POST created nothing: a bare "0 created" says no reason, the
122
+ // notes ("--only fp: no such open finding") do. When something was created the notes are not about the dialog.
123
+ function emptyNotes(created, notes) {
124
+ return list(created).some(c => c && c.url) ? [] : list(notes).map(n => String(n)).filter(Boolean);
125
+ }
107
126
  // An untrusted URL becomes a link only when it is a plain https address.
108
127
  const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
128
+ // The marker for a finding whose whole-run review is outdated, or null. The lamp is the hollow unlit
129
+ // `.st-outdated`. `title` is the same sentence the issue body adds, ids cut after 5, so a row can show it
130
+ // without building HTML. A current review, and any finding that is not from a whole-run review, has none.
131
+ function reviewMarker(row) {
132
+ const review = row && row.review;
133
+ if (!review || review.outdated !== true) return null;
134
+ const ids = list(review.merged_since).map(id => String(id));
135
+ const shown = ids.slice(0, 5), extra = ids.length - shown.length;
136
+ const names = shown.join(', ') + (extra > 0 ? ', +' + extra + ' more' : '');
137
+ const n = ids.length;
138
+ const sentence = `The review is outdated: ${n} ${n === 1 ? 'task' : 'tasks'} merged into the integration branch after it`
139
+ + (names ? ` (${names})` : '') + ', so check whether they fixed it first.';
140
+ return { lamp: 'st-outdated', label: 'outdated review', title: sentence, sentence };
141
+ }
109
142
 
110
143
  /* ---------- DOM ---------- */
111
144
  function render(host, ctx) {
@@ -162,7 +195,7 @@
162
195
  const dismissKey = key_('Dismiss', undefined, () => openDismiss());
163
196
  const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
164
197
  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);
198
+ for (const fp of selectShownOpen(shown(), s.selected)) s.selected.add(fp);
166
199
  drawTable(); syncActions();
167
200
  });
168
201
  const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
@@ -179,6 +212,9 @@
179
212
  host.append(dlg);
180
213
 
181
214
  const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
215
+ // What an action takes: the selected rows the filters leave visible. Rows hidden by a filter stay in s.selected, so
216
+ // they come back with the filter, but no button counts them and no action reaches them.
217
+ const picked = action => selection(action, shown(), s.selected);
182
218
  function syncScope() {
183
219
  for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
184
220
  lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
@@ -187,17 +223,27 @@
187
223
  function syncActions() {
188
224
  actions.hidden = !s.writes;
189
225
  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);
226
+ const f = picked('file'), d = picked('dismiss'), u = picked('undismiss');
191
227
  fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
192
228
  fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
193
229
  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` : '');
230
+ actionNote.textContent = f.note || d.note || u.note || (f.count + u.count ? `${f.count} open, ${u.count} dismissed selected` : '');
195
231
  }
196
232
  function setLamp(cls, text) {
197
233
  lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
198
234
  }
199
235
 
200
236
  // cells
237
+ const originCell = r => {
238
+ const marker = reviewMarker(r);
239
+ if (!marker) return r.origin || '—';
240
+ const box = el('span', undefined, 'fnd-status');
241
+ const lamp = el('i', undefined, 'lamp ' + marker.lamp);
242
+ lamp.title = marker.title;
243
+ box.title = marker.title;
244
+ box.append(lamp, el('span', r.origin || '—'), el('span', marker.label));
245
+ return box;
246
+ };
201
247
  const statusCell = r => {
202
248
  const st = statusOf(r), box = el('span', undefined, 'fnd-status fnd-' + st);
203
249
  box.append(el('i', undefined, 'lamp ' + (st === 'filed' ? 'fnd-amber' : st === 'fixed' ? 'st-pass' : 'lamp-off')));
@@ -223,6 +269,8 @@
223
269
  more.append(el('span', '▸', 'fnd-caret'));
224
270
  const detail = el('div', undefined, 'fnd-detail'); detail.hidden = true;
225
271
  detail.append(el('pre', r.text || r.summary || '', 'fnd-text'));
272
+ const marker = reviewMarker(r);
273
+ if (marker) detail.append(el('p', marker.sentence, 'muted'));
226
274
  const meta = el('dl', undefined, 'fnd-meta');
227
275
  for (const [k, v] of [['Reviewer', r.reviewer], ['Commit', r.commit], ['Fingerprint', r.fp], ['Plan', r.plan_dir || r.plan],
228
276
  ['Dismissed', r.dismissed ? `${r.dismissed.at || ''} ${r.dismissed.reason || ''}`.trim() : null]]) {
@@ -245,7 +293,7 @@
245
293
  cols.push(
246
294
  { id: 'severity', label: 'Severity', value: r => SEVERITIES.indexOf(r.severity), cell: sevCell },
247
295
  { 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 || '—' },
296
+ { id: 'origin', label: 'Origin', value: r => r.origin, cell: originCell },
249
297
  { id: 'task', label: 'Task', value: r => r.task, mono: true, cell: r => r.task || 'whole run' });
250
298
  if (s.scope === 'repo') cols.push({ id: 'plan', label: 'Plan', value: r => r.plan, mono: true, cell: r => r.plan || '—' });
251
299
  cols.push(
@@ -304,7 +352,7 @@
304
352
  // dismiss: an optional reason, then the call
305
353
  async function openDismiss() {
306
354
  reasonHost.replaceChildren();
307
- const sel = selection('dismiss', s.rows, s.selected);
355
+ const sel = picked('dismiss');
308
356
  if (!sel.enabled) return;
309
357
  const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
310
358
  input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
@@ -319,7 +367,7 @@
319
367
  input.focus();
320
368
  }
321
369
  async function doDismiss(undo, reason) {
322
- const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
370
+ const sel = picked(undo ? 'undismiss' : 'dismiss');
323
371
  if (!sel.enabled || s.busy) return;
324
372
  s.busy = true; syncActions(); outcomeHost.replaceChildren();
325
373
  const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
@@ -330,17 +378,19 @@
330
378
 
331
379
  // filing: drafts for exactly the selection, a preview, then the POST
332
380
  async function openFile() {
333
- const sel = selection('file', s.rows, s.selected);
381
+ const sel = picked('file');
334
382
  if (!sel.enabled || s.busy) return;
335
383
  s.busy = true; syncActions(); outcomeHost.replaceChildren();
336
384
  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 }));
385
+ // The scope and the toggle stay usable while the drafts are made: the preview files what these drafts were made for.
386
+ const scope = s.scope, plausible = s.plausible;
387
+ const res = await UI.get(issuesUrl(key, { scope, offline: false, plausible, only: sel.fps }));
338
388
  s.busy = false; syncActions();
339
389
  if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
340
390
  setLamp('st-pass', 'drafts ready');
341
- preview(sel.fps, list(res.data.drafts));
391
+ preview(sel.fps, list(res.data.drafts), { scope, plausible });
342
392
  }
343
- function preview(fps, drafts) {
393
+ function preview(fps, drafts, made) {
344
394
  dlg.replaceChildren();
345
395
  dlg.append(el('h2', 'File issues on GitHub'));
346
396
  const warn = el('p', undefined, 'fnd-warn');
@@ -373,7 +423,7 @@
373
423
  labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
374
424
  if (parsed.error) return;
375
425
  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));
426
+ const res = await UI.post(issuesPath(key), fileBody(made.scope, fps, made.plausible, parsed.labels));
377
427
  posting = false;
378
428
  if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
379
429
  const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
@@ -384,6 +434,7 @@
384
434
  ul.append(li);
385
435
  }
386
436
  results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
437
+ for (const n of emptyNotes(res.data.created, res.data.notes)) results.append(el('p', n, 'muted fnd-result-note'));
387
438
  go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
388
439
  for (const fp of fps) s.selected.delete(fp);
389
440
  load();
@@ -407,6 +458,6 @@
407
458
  return { reload: load };
408
459
  }
409
460
 
410
- return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
411
- parseLabels, resultLines, safeUrl, MAX_FPS };
461
+ return { render, statusOf, filterRows, mergeFiled, selectable, selection, selectShownOpen, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
462
+ parseLabels, resultLines, emptyNotes, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
412
463
  });
@@ -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);
@@ -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
  });
@@ -9,7 +9,7 @@ window.ForgePanel = (() => {
9
9
  const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
10
10
  const display = v => v === null || v === undefined || v === '' ? '—' : typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v);
11
11
  const STATUSES = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra', 'timeout', 'unknown', 'invalidated', 'blocked',
12
- 'interrupted', 'nochanges', 'conflict', 'accepted', 'none']);
12
+ 'interrupted', 'nochanges', 'conflict', 'accepted', 'none', 'outdated']);
13
13
  const cache = new Map(); // path → {data, error, at}
14
14
  function el(tag, text, className) {
15
15
  const node = document.createElement(tag);
@@ -51,6 +51,7 @@ window.ForgePanel = (() => {
51
51
  function section(parent, title) { const s = el('section', undefined, 'insp-section'); s.append(el('h3', title)); parent.append(s); return s; }
52
52
  function details(parent, title, value, key, { open = false, cls = '' } = {}) {
53
53
  const d = el('details', undefined, cls); d.dataset.keep = key; if (open) d.open = true;
54
+ if (key && String(key).endsWith('known_issues')) d.id = key;
54
55
  const pre = el('pre', value); pre.dataset.scroll = key + '-text';
55
56
  d.append(el('summary', title), pre); parent.append(d); return d;
56
57
  }
@@ -60,6 +61,21 @@ window.ForgePanel = (() => {
60
61
  return a;
61
62
  }
62
63
  function muted(parent, text) { parent.append(el('p', text, 'muted')); }
64
+ // The acceptance chip is a key to the Known issues text in this panel, not another route.
65
+ function acceptedChip(task) {
66
+ const over = task.accepted_over === 'FAIL' || task.accepted_over === 'UNKNOWN' ? task.accepted_over : null;
67
+ const id = 'task-' + task.id + '-known_issues';
68
+ const a = el('a', over ? 'accepted over ' + over : 'accepted', 'chip st-accepted');
69
+ a.href = '#' + id;
70
+ a.addEventListener('click', event => {
71
+ event.preventDefault();
72
+ const node = document.getElementById(id);
73
+ if (!node) return;
74
+ node.open = true;
75
+ node.scrollIntoView({ block: 'nearest' });
76
+ });
77
+ return a;
78
+ }
63
79
  // who decided an escalation: policy, a human's retry --dwarf (retry) or a pin; older rows read as policy
64
80
  const sourcePlate = source => el('span', source || 'policy', 'plate src-plate src-' + String(source || 'policy').replace(/[^a-z]/g, ''));
65
81
  // 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
@@ -218,11 +234,23 @@ window.ForgePanel = (() => {
218
234
  }
219
235
  const f = section(host, 'Finish');
220
236
  const F = RM.finishState(run);
221
- if (run.kind === 'solo') fields(f, [['Verdict', chip(list(run.tasks)[0] && list(run.tasks)[0].verdict)]]);
237
+ if (run.kind === 'solo') {
238
+ const soloTask = list(run.tasks)[0];
239
+ const verdict = soloTask ? RM.taskVerdict(soloTask) : { text: null, status: 'unknown' };
240
+ fields(f, [['Verdict', chip(verdict.text, verdict.status)]]);
241
+ }
222
242
  else {
243
+ const finalSel = { kind: 'final' }, openFinal = () => ctx.onSelect(finalSel);
244
+ let finalValue;
245
+ if (F.final.status === 'OUTDATED') {
246
+ finalValue = el('a'); finalValue.href = ctx.hash(finalSel);
247
+ finalValue.addEventListener('click', e => { e.preventDefault(); openFinal(); });
248
+ finalValue.append(chip('OUTDATED'));
249
+ } else finalValue = link(F.final.status === 'NONE' ? 'not requested' : F.final.status, ctx.hash(finalSel), openFinal);
223
250
  fields(f, [['Integration', link(`${F.integration.merged}/${F.integration.total} merged`, ctx.hash({ kind: 'integration' }), () => ctx.onSelect({ kind: 'integration' }))],
224
251
  ['Verification', link(F.verification.status, ctx.hash({ kind: 'verification' }), () => ctx.onSelect({ kind: 'verification' }))],
225
- ['Final review', link(F.final.status === 'NONE' ? 'not requested' : F.final.status, ctx.hash({ kind: 'final' }), () => ctx.onSelect({ kind: 'final' }))]]);
252
+ ['Final review', finalValue]]);
253
+ if (F.final.status === 'OUTDATED') muted(f, RM.reviewSinceLine(F.final));
226
254
  if (F.final.findings) findingCounts(f, F.final.findings);
227
255
  }
228
256
  if (list(run.deferrals).length) details(host, `Deferred tasks (${run.deferrals.length})`, run.deferrals.join('\n'), 'deferrals');
@@ -236,10 +264,13 @@ window.ForgePanel = (() => {
236
264
  const head = el('div', undefined, 'insp-head');
237
265
  head.append(el('h2', solo ? (task.title || 'Solo run') : `${task.id} · ${task.title || 'Untitled task'}`), el('div', solo ? 'solo task' : `task · wave ${display(task.wave)}`, 'insp-kind'));
238
266
  const chips = el('div', undefined, 'chips');
239
- chips.append(chip(task.status));
240
- if (task.verdict) chips.append(chip('review ' + task.verdict, task.verdict.toLowerCase()));
267
+ if (RM.taskStatus(task) === 'ACCEPTED') chips.append(acceptedChip(task));
268
+ else {
269
+ chips.append(chip(RM.taskStatus(task)));
270
+ if (task.accepted === true) chips.append(acceptedChip(task));
271
+ else if (task.verdict) chips.append(chip('review ' + task.verdict, task.verdict.toLowerCase()));
272
+ }
241
273
  if (task.explored) chips.append(el('span', '* explored', 'tag'));
242
- if (task.accepted_by) chips.append(chip('accepted over FAIL', 'nochanges'));
243
274
  head.append(chips);
244
275
  const dl = diffLink(ctx, solo ? 'View the diff →' : 'View its diff, findings pinned to their lines →', { kind: 'task', task: task.id, source: 'current' }); if (dl) head.append(dl);
245
276
  host.append(head);
@@ -270,7 +301,7 @@ window.ForgePanel = (() => {
270
301
  for (const z of tp.zones) {
271
302
  const box = el('div', undefined, 'attempt');
272
303
  const row = list(task.attempts).filter(a => a.attempt === z.attempt).pop();
273
- const h = el('div', undefined, 'attempt-head'); h.append(el('strong', z.name.toLowerCase())); if (row) h.append(chip(row.status), el('span', time(row.at), 'muted'));
304
+ const h = el('div', undefined, 'attempt-head'); h.append(el('strong', z.name.toLowerCase())); if (row) h.append(chip(RM.attemptStatus(task, row.attempt, row.status)), el('span', time(row.at), 'muted'));
274
305
  box.append(h);
275
306
  for (const pid of z.parts) {
276
307
  const p = tp.parts.find(x => x.id === pid); if (!p || p.planned) continue;
@@ -329,7 +360,8 @@ window.ForgePanel = (() => {
329
360
  const cs = section(host, 'Clef predictions');
330
361
  for (const [label, p] of preds) {
331
362
  const n = Number(String(label).match(/\d+/) || 1), outcome = list(task.attempts).filter(a => a.attempt === n).pop();
332
- fields(cs, [[label, `p(pass) = ${display(p.p)} → ${outcome ? outcome.status : 'not finished'}`], ['Assignment', p.assignment ? plates([p.assignment.dwarf, p.assignment.qa]) : '—']]);
363
+ const happened = outcome ? RM.attemptStatus(task, outcome.attempt, outcome.status) : 'not finished';
364
+ fields(cs, [[label, `p(pass) = ${display(p.p)} → ${happened}`], ['Assignment', p.assignment ? plates([p.assignment.dwarf, p.assignment.qa]) : '—']]);
333
365
  }
334
366
  }
335
367
  // the prompts and the final messages
@@ -338,10 +370,18 @@ window.ForgePanel = (() => {
338
370
  else {
339
371
  const t = detail.data.texts || {};
340
372
  for (const [title, key] of [['Requirements (prompt.md)', 'prompt'], ['Intended approach', 'approach'], ['Retry findings', 'retry_findings'],
341
- ['Dwarf prompt as assembled (dwarf.input)', 'dwarf_input'], ['Review prompt as assembled (qa.input)', 'qa_input'], ['Known issues', 'known_issues'], ['Accepted by', 'accepted_by']]) {
373
+ ['Dwarf prompt as assembled (dwarf.input)', 'dwarf_input'], ['Review prompt as assembled (qa.input)', 'qa_input']]) {
342
374
  if (t[key] != null) details(ps, title, t[key], `task-${task.id}-${key}`);
343
375
  }
344
376
  }
377
+ // Known issues is the text the acceptance chip opens. Accepted by keeps the reason, who, and when.
378
+ const texts = detail.data && detail.data.texts || null;
379
+ if (task.accepted === true || (texts && texts.known_issues != null)) {
380
+ const known = texts ? texts.known_issues : null;
381
+ details(ps, 'Known issues', known != null ? known : (detail.data ? '—' : 'Loading…'), `task-${task.id}-known_issues`);
382
+ }
383
+ const acceptedBy = texts && texts.accepted_by != null ? texts.accepted_by : task.accepted_by;
384
+ if (acceptedBy != null) details(ps, 'Accepted by', acceptedBy, `task-${task.id}-accepted_by`);
345
385
  if (task.dwarf_tail != null) details(ps, 'Dwarf final message', task.dwarf_tail, `task-${task.id}-dwarf_tail`);
346
386
  if (task.qa_tail != null) details(ps, 'Reviewer final message', task.qa_tail, `task-${task.id}-qa_tail`);
347
387
  }
@@ -475,10 +515,12 @@ window.ForgePanel = (() => {
475
515
  return;
476
516
  }
477
517
  const chips = el('div', undefined, 'chips');
478
- chips.append(chip(pick.state === 'running' ? 'RUNNING' : pick.verdict || pick.state || 'unknown'));
518
+ const status = RM.finalStatus(pick);
519
+ chips.append(chip(status));
479
520
  if (pick.published) chips.append(el('span', 'published', 'tag'));
480
521
  chips.append(el('span', pick.source === 'integrate' ? 'during integrate' : 'forge review', 'tag'));
481
522
  head.append(chips);
523
+ if (status === 'OUTDATED') muted(host, RM.reviewSinceLine(pick));
482
524
  fields(host, [['Reviewer', plate(pick.spec)], ['Threshold', pick.threshold], ['Reviewed', pick.base || pick.tip ? `${String(pick.base || '?').slice(0, 10)}..${String(pick.tip || '?').slice(0, 10)}` : '—', true],
483
525
  ['Started', time(pick.started ?? pick.at)], ['Ended', time(pick.ended)], ['Duration', RM.fmtDur(pick.duration_s)]]);
484
526
  if (pick.execution) { const s = { kind: 'execution', task: null, exec: pick.execution }; host.append(link('Model, timings and exact prompt →', ctx.hash(s), () => ctx.onSelect(s))); }
@@ -113,7 +113,9 @@
113
113
  if (f.model && !list(run.models).some(m => alias(m) === f.model)) return false;
114
114
  if (f.ageDays && num(now) !== null && !(num(run.updated) !== null && run.updated >= now - f.ageDays * 86400)) return false;
115
115
  if (f.verdict) {
116
- const v = run.final_review || run.verification_status || null;
116
+ // An outdated published review matches OUTDATED, not the verdict it gave.
117
+ const review = runReviewStatus(run);
118
+ const v = review != null ? review : (run.verification_status || null);
117
119
  if (f.verdict === 'none' ? v : v !== f.verdict) return false;
118
120
  }
119
121
  return true;
@@ -180,16 +182,116 @@
180
182
  if (statuses.some(s => s === 'PENDING' || s === 'PASS')) return 'PENDING';
181
183
  return 'BLOCKED';
182
184
  }
185
+ // How one final review reads as a stage. OUTDATED only once it is done and the reader says the
186
+ // integration branch has moved: a running review, and a review the reader still calls current, keep their verdict.
187
+ function finalStatus(fr) {
188
+ if (!fr || typeof fr !== 'object') return null;
189
+ if (fr.state === 'running') return 'RUNNING';
190
+ if (fr.state === 'infra') return 'INFRA';
191
+ if (fr.state === 'done' && fr.outdated === true) return 'OUTDATED';
192
+ return fr.verdict || 'UNKNOWN';
193
+ }
194
+ // RunSummary carries the published verdict as a string plus final_review_outdated. Same rule as finalStatus.
195
+ function runReviewStatus(summary) {
196
+ if (!summary) return null;
197
+ const v = summary.final_review;
198
+ if (v && typeof v === 'object') return finalStatus(v);
199
+ if (typeof v !== 'string' || !v) return null;
200
+ return summary.final_review_outdated === true ? 'OUTDATED' : v;
201
+ }
202
+ // The lamp and status word for a task. Accepting a merge does not change the recorded status
203
+ // or the run's counts: only the display says ACCEPTED, and only once the task has merged.
204
+ function taskStatus(t) {
205
+ if (!t) return 'PENDING';
206
+ if (t.status === 'MERGED' && t.accepted === true) return 'ACCEPTED';
207
+ return t.status || 'PENDING';
208
+ }
209
+ // Verdict column and the 2D graph's tick or cross. text is the words, status the lamp class,
210
+ // mark the cross (FAIL) or tick (PASS). An acceptance names FAIL or UNKNOWN and never invents a FAIL,
211
+ // and it draws no mark: the graph must not put a red cross on a task a human accepted.
212
+ function taskVerdict(t) {
213
+ if (taskStatus(t) === 'ACCEPTED') {
214
+ const over = t.accepted_over === 'FAIL' || t.accepted_over === 'UNKNOWN' ? t.accepted_over : null;
215
+ return { text: over ? over + ' · accepted' : 'accepted', status: 'accepted', mark: null };
216
+ }
217
+ const v = t && t.verdict ? String(t.verdict) : null;
218
+ return { text: v, status: v ? v.toLowerCase() : 'unknown', mark: v === 'PASS' || v === 'FAIL' ? v : null };
219
+ }
220
+ // One attempt in the history. The attempt that was accepted is ACCEPTED; every earlier attempt
221
+ // keeps the status it ended with. execStatus itself is unchanged, so a QA part still shows the
222
+ // reviewer's verdict and an execution detail does too.
223
+ function attemptStatus(task, attempt, raw) {
224
+ if (taskStatus(task) !== 'ACCEPTED') return raw;
225
+ const n = num(attempt), want = num(task.accepted_attempt);
226
+ if (n !== null && want !== null && n === want) return 'ACCEPTED';
227
+ return raw;
228
+ }
229
+ function shortTip(tip) {
230
+ if (tip === null || tip === undefined || tip === '') return '—';
231
+ return String(tip).slice(0, 7);
232
+ }
233
+ // "<verdict> at <tip7>; N tasks merged since: <ids>", ids cut after 5.
234
+ function reviewSinceLine(src) {
235
+ if (!src) return '';
236
+ const ids = list(src.merged_since).map(id => String(id));
237
+ const shown = ids.slice(0, 5), extra = ids.length - shown.length;
238
+ const names = shown.join(', ') + (extra > 0 ? ' +' + extra + ' more' : '');
239
+ const n = ids.length;
240
+ return `${src.verdict || 'UNKNOWN'} at ${shortTip(src.tip)}; ${n} ${n === 1 ? 'task' : 'tasks'} merged since` + (names ? ': ' + names : '');
241
+ }
242
+ // The newest final review that had started by t. Same pick stateAt has always made.
243
+ function reviewAt(run, t) {
244
+ const started = r => num(r && r.started) ?? num(r && r.at);
245
+ const fr = list(run && run.final_reviews).filter(r => r && (started(r) ?? Infinity) <= t);
246
+ return fr.sort((a, b) => (a.at || 0) - (b.at || 0)).pop() || null;
247
+ }
248
+ // When an outdated review starts to show as OUTDATED. The current flag is the truth; this only places it.
249
+ // First id in merged_since, else (that list is empty) the first merge at or after the review's `at`,
250
+ // else the review's end when no merge time is known. A current review (outdated false or null) has no moment.
251
+ function outdatedMoment(run, review) {
252
+ if (!review || review.outdated !== true) return null;
253
+ const merges = list(run && run.integration && run.integration.merged).filter(m => m && num(m.at) !== null);
254
+ const since = list(review.merged_since);
255
+ if (since.length) {
256
+ const times = merges.filter(m => m.id === since[0]).map(m => m.at);
257
+ if (times.length) return Math.min(...times);
258
+ return num(review.ended);
259
+ }
260
+ const at = num(review.at);
261
+ if (at !== null) {
262
+ const later = merges.filter(m => m.at >= at).map(m => m.at);
263
+ if (later.length) return Math.min(...later);
264
+ }
265
+ return num(review.ended);
266
+ }
267
+ // Status of one review at time t. Before it ends it is RUNNING. OUTDATED only at and after outdatedMoment,
268
+ // and only when the review is done and currently outdated, so the present matches finalStatus.
269
+ function finalAt(run, review, t) {
270
+ if (!review) return 'NONE';
271
+ if (review.state === 'running') return 'RUNNING';
272
+ const ended = num(review.ended);
273
+ const end = ended !== null ? ended : (review.state === 'done' || review.state === 'infra' ? num(review.at) : null);
274
+ if (end === null) return finalStatus(review) || 'RUNNING';
275
+ if (end > t) return 'RUNNING';
276
+ if (review.state === 'infra') return 'INFRA';
277
+ if (review.state === 'done' && review.outdated === true) {
278
+ const from = outdatedMoment(run, review);
279
+ if (from === null || t >= from) return 'OUTDATED';
280
+ }
281
+ return review.verdict || 'UNKNOWN';
282
+ }
183
283
  function finishState(run) {
184
284
  const fr = run.final_review, v = run.verification, ig = run.integration;
185
285
  const tasks = list(run.tasks), merged = tasks.filter(t => t.status === 'MERGED').length;
286
+ const frObj = fr && typeof fr === 'object' ? fr : null;
186
287
  return {
187
288
  integration: { status: integrationStatus(tasks.map(t => t.status)),
188
289
  merged, total: tasks.length, base: ig && ig.base || null, accepted: ig && ig.accepted || null },
189
290
  verification: { status: v && v.status ? String(v.status).toUpperCase() : 'PENDING', command: v && v.command || null, runs: list(v && v.runs).length },
190
- final: { status: !fr ? (run.settings && run.settings.final_qa ? 'PENDING' : 'NONE') : fr.state === 'running' ? 'RUNNING' : fr.state === 'infra' ? 'INFRA' : fr.verdict || 'UNKNOWN',
191
- requested: !!(fr || run.settings && run.settings.final_qa), spec: fr && fr.spec || run.settings && run.settings.final_qa || null, findings: fr && fr.findings || null,
192
- count: list(run.final_reviews).length },
291
+ final: { status: !frObj ? (run.settings && run.settings.final_qa ? 'PENDING' : 'NONE') : finalStatus(frObj),
292
+ requested: !!(fr || run.settings && run.settings.final_qa), spec: frObj && frObj.spec || run.settings && run.settings.final_qa || null, findings: frObj && frObj.findings || null,
293
+ count: list(run.final_reviews).length,
294
+ verdict: frObj && frObj.verdict || null, tip: frObj && frObj.tip || null, merged_since: list(frObj && frObj.merged_since) },
193
295
  };
194
296
  }
195
297
  function planModel(run) {
@@ -221,6 +323,8 @@
221
323
  const put = (a, p) => { const k = a == null ? 'other' : String(a); if (!byAttempt.has(k)) byAttempt.set(k, []); byAttempt.get(k).push(p); };
222
324
  for (const e of execs) {
223
325
  if (e.role !== 'dwarf' && e.role !== 'qa') continue;
326
+ // The part keeps execStatus. The accepted attempt is relabelled by attemptStatus on the
327
+ // attempt itself; painting this part ACCEPTED would hide the reviewer's verdict.
224
328
  put(e.attempt, { id: e.id, kind: e.role, exec: e, status: execStatus(e), label: e.role === 'dwarf' ? 'DWARF' : 'QA', spec: e.model && e.model.spec || null, attempt: e.attempt });
225
329
  }
226
330
  for (const s of list(task && task.shadow)) {
@@ -359,11 +463,13 @@
359
463
  const start = num(e.started); if (start === null) continue;
360
464
  const kids = execs.filter(k => k !== e && (k.parent === e.id || e.synthetic || (tops.length === 1 && !k.parent && (k.role === 'dwarf' || k.role === 'qa'))));
361
465
  const row = rows.filter(r => r.attempt === e.attempt).pop();
362
- const status = e.state === 'running' ? 'RUNNING' : row ? row.status : e.state === 'orphaned' ? 'INTERRUPTED' : t.status === 'MERGED' && e === tops[tops.length - 1] ? 'MERGED' : execStatus(e);
466
+ let status = e.state === 'running' ? 'RUNNING' : row ? row.status : e.state === 'orphaned' ? 'INTERRUPTED' : t.status === 'MERGED' && e === tops[tops.length - 1] ? 'MERGED' : execStatus(e);
467
+ if (e === tops[tops.length - 1] && t.status === 'MERGED') status = 'MERGED';
468
+ status = attemptStatus(t, e.attempt, status);
363
469
  const end = execEnd(e, now);
364
470
  const running = kids.find(k => k.state === 'running');
365
471
  items.push({ id: e.id, task: t.id, attempt: e.attempt, start, end: end !== null ? Math.max(end, start) : start, running: e.state === 'running',
366
- status: e === tops[tops.length - 1] && t.status === 'MERGED' ? 'MERGED' : status, role: running ? running.role : null, phase: running ? running.phase : null,
472
+ status, role: running ? running.role : null, phase: running ? running.phase : null,
367
473
  segs: kids.filter(k => k.role === 'dwarf' || k.role === 'qa').map(k => ({ id: k.id, role: k.role, start: num(k.started), end: execEnd(k, now), status: execStatus(k) })).filter(s => s.start !== null) });
368
474
  }
369
475
  }
@@ -492,9 +598,8 @@
492
598
  }
493
599
  tasks[task.id] = { status, role };
494
600
  }
495
- const fr = list(run && run.final_reviews).filter(r => (num(r.started) ?? num(r.at) ?? Infinity) <= t);
496
- const last = fr.sort((a, b) => (a.at || 0) - (b.at || 0)).pop();
497
- const final = !last ? 'NONE' : num(last.ended) !== null && last.ended <= t ? (last.verdict || 'UNKNOWN') : 'RUNNING';
601
+ const last = reviewAt(run, t);
602
+ const final = finalAt(run, last, t);
498
603
  const vs = list(run && run.timeline && run.timeline.verifications).filter(v => num(v.started) !== null && v.started <= t);
499
604
  const lv = vs.pop();
500
605
  const verification = !lv ? 'PENDING' : num(lv.ended) !== null && lv.ended <= t ? (lv.exit_code === 0 ? 'PASS' : 'FAIL') : 'RUNNING';
@@ -502,7 +607,15 @@
502
607
  }
503
608
  function timeRange(run, now) {
504
609
  const s = [], e = [];
505
- for (const t of list(run && run.tasks)) for (const x of list(t.executions)) { if (num(x.started) !== null) s.push(x.started); const end = execEnd(x, now); if (end !== null) e.push(end); }
610
+ for (const t of list(run && run.tasks)) {
611
+ for (const x of list(t.executions)) { if (num(x.started) !== null) s.push(x.started); const end = execEnd(x, now); if (end !== null) e.push(end); }
612
+ // The merged marker is written after the attempt ends. Leave it out and the slider stops on the
613
+ // verdict while the current view already shows the review as OUTDATED.
614
+ if (num(t.merged_at) !== null) e.push(t.merged_at);
615
+ }
616
+ // Same marker, as integration.merged. That list is what outdatedMoment reads, and a run detail can carry it
617
+ // without the task's merged_at matching, so the range has to take it on its own.
618
+ for (const m of list(run && run.integration && run.integration.merged)) if (num(m && m.at) !== null) e.push(m.at);
506
619
  for (const r of list(run && run.final_reviews)) { const v = num(r.started) ?? num(r.at); if (v !== null) s.push(v); if (num(r.ended) !== null) e.push(r.ended); }
507
620
  for (const v of list(run && run.timeline && run.timeline.verifications)) { if (num(v.started) !== null) s.push(v.started); if (num(v.ended) !== null) e.push(v.ended); }
508
621
  if (!s.length) return null;
@@ -556,8 +669,9 @@
556
669
  if (r.kind === 'solo') continue;
557
670
  for (const t of list(r.task_statuses)) {
558
671
  if (!t || !t.id) continue;
559
- out.push({ id: `task:${r.key}:${t.id}`, kind: 'task', key: r.key, task: t.id, label: t.title ? `${t.id} · ${t.title}` : t.id, sub: `${goal} · ${String(t.status || 'PENDING').toLowerCase()}`,
560
- ids: [t.id], words: [r.run_id, basename(r.repo)].filter(Boolean), t: num(r.updated) || 0, status: t.status, subSearch: false, // not found through its run's goal
672
+ const shown = taskStatus(t);
673
+ out.push({ id: `task:${r.key}:${t.id}`, kind: 'task', key: r.key, task: t.id, label: t.title ? `${t.id} · ${t.title}` : t.id, sub: `${goal} · ${String(shown).toLowerCase()}`,
674
+ ids: [t.id], words: [r.run_id, basename(r.repo)].filter(Boolean), t: num(r.updated) || 0, status: shown, subSearch: false, // not found through its run's goal
561
675
  href: `#/runs/${encodeURIComponent(r.key)}/tasks/${encodeURIComponent(t.id)}` });
562
676
  }
563
677
  }
@@ -661,7 +775,8 @@
661
775
  return { pos, edges, cols, rows: next };
662
776
  }
663
777
 
664
- return { liveNow, runState, execStatus, matchRun, filterOptions, atlasModel, columns, planModel, finishState, taskParts,
778
+ return { liveNow, runState, execStatus, matchRun, filterOptions, atlasModel, columns, planModel, finishState,
779
+ finalStatus, runReviewStatus, reviewSinceLine, reviewAt, taskStatus, taskVerdict, attemptStatus, taskParts,
665
780
  taskTimes, metricValue, overlap, projectSchedule, execModel, criticalPath, concurrency, stateAt, timeRange, events, lineage, structureKey, innerKey,
666
781
  fmtDur, alias, basename, weight, searchIndex, searchRank, statusEvents, fractalTree };
667
782
  });