@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
@@ -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
  });
@@ -89,12 +89,13 @@ export async function createStage(host, cb = {}) {
89
89
  el.col = col || E.statusColor(el.status);
90
90
  el.pulse = el.status === 'RUNNING';
91
91
  }
92
- const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN']);
92
+ const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN', 'OUTDATED']); // unlit: no glow and no tint
93
93
  function baseTargets(el) {
94
94
  const T = el.tgt, s = el.status, light = E.theme === 'light';
95
95
  T.lit = PLAIN.has(s) ? 0 : .32;
96
96
  T.tint = PLAIN.has(s) ? 0 : light ? .34 : .1; // a pale panel shows a lit section by its colour: no glow to carry it
97
97
  T.vis = 1; T.op = 1; T.opB = 1;
98
+ // Not started yet. OUTDATED is a finished review that is no longer current, so it stays solid.
98
99
  if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
99
100
  // parts are small: their status has to read from the colour of the whole piece
100
101
  if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
@@ -166,7 +167,8 @@ export async function createStage(host, cb = {}) {
166
167
  const parts = [r.kind === 'solo' ? (Object.keys(c)[0] || 'solo') : `${merged}/${total} done`];
167
168
  if (running) parts.push(`${running} running`);
168
169
  if (bad) parts.push(`${bad} failed`);
169
- if (r.final_review) parts.push(`review ${r.final_review}`);
170
+ const reviewStatus = RM.runReviewStatus(r);
171
+ if (reviewStatus) parts.push(`review ${reviewStatus}`);
170
172
  else if (r.verification_status) parts.push(`verify ${r.verification_status}`);
171
173
  return parts.join(' · ');
172
174
  }
@@ -191,7 +193,7 @@ export async function createStage(host, cb = {}) {
191
193
  cx.fillText(E.ellipsis(`${RM.basename(r.repo)} · ${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, Math.floor((w - 30) / (7 * E.CW))), 2 * s, 44 * s);
192
194
  }, 'cover', { x: x + 12, y: y + 8, z: h });
193
195
  leds(el, Math.min(60, m.tasks.length), x + 17, y + d - 14, h + .16, { step: 8, s: 2.4, perRow: Math.max(4, Math.floor((w - 30) / 8)) });
194
- setLeds(el, m.tasks.map(t => t.status));
196
+ setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
195
197
  display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
196
198
  halo(el);
197
199
  }
@@ -206,17 +208,28 @@ export async function createStage(host, cb = {}) {
206
208
  const el = EL.get('r:' + m.id); if (!el) continue;
207
209
  el.data.run = m.run; el.data.module = m;
208
210
  setStatus(el, STATE_STATUS[m.state]); halo(el);
209
- setLeds(el, m.tasks.map(t => t.status));
210
- const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.verification_status]);
211
+ setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
212
+ const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.final_review_outdated, m.run.verification_status]);
211
213
  if (sig !== el.sig) { el.sig = sig; redraw(el); }
212
214
  }
213
215
  retarget();
214
216
  }
215
217
 
216
218
  /* ---------- plan: waves, tasks, the finish line ---------- */
217
- function taskStatus(t) {
218
- if (st.replay !== null && st.replayState) { const s = st.replayState.tasks[t.id]; return s ? s.status : 'PENDING'; }
219
- return t.status || 'PENDING';
219
+ // During a replay the recorded status is what it was then; acceptance shows only once that status is MERGED.
220
+ function shownTask(t) {
221
+ if (t && st.replay !== null && st.replayState) {
222
+ const s = st.replayState.tasks[t.id];
223
+ return Object.assign({}, t, { status: s ? s.status : 'PENDING' });
224
+ }
225
+ return t;
226
+ }
227
+ function taskStatus(t) { return RM.taskStatus(shownTask(t)); }
228
+ function attemptLamps(t) {
229
+ const shown = shownTask(t);
230
+ const rows = list(t.attempts).map(a => RM.attemptStatus(shown, a.attempt, a.status));
231
+ if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
232
+ return rows;
220
233
  }
221
234
  function taskLine(el) {
222
235
  const t = el.data.task, s = el.status;
@@ -256,6 +269,7 @@ export async function createStage(host, cb = {}) {
256
269
  const tries = Math.max(1, list(t.attempts).length);
257
270
  leds(el, Math.min(20, tries), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
258
271
  el.ledCount = tries;
272
+ setLeds(el, attemptLamps(t).slice(-el.ledCount));
259
273
  display(el, taskLine, { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
260
274
  // the Clef ring: how likely the first try was to pass, in the colour of what happened
261
275
  const p0 = t.clef && t.clef.first_pass && num(t.clef.first_pass.p);
@@ -276,9 +290,12 @@ export async function createStage(host, cb = {}) {
276
290
  const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
277
291
  if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
278
292
  if (id === 'f:verification') return { title: 'VERIFICATION', sub: F.verification.command || 'no command recorded', status: F.verification.status, line: F.verification.runs ? `${F.verification.runs} run${F.verification.runs > 1 ? 's' : ''}` : 'not run yet' };
293
+ const status = finishStatus(id, { status: F.final.status });
294
+ const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
279
295
  const f = F.final.findings;
280
- return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status: F.final.status, findings: f,
281
- line: F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : F.final.status };
296
+ const line = status === 'OUTDATED' ? RM.reviewSinceLine(src)
297
+ : F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : status;
298
+ return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status, findings: f, line };
282
299
  }
283
300
  function buildFinish(m, p, g) {
284
301
  const [x, y, w, d, h] = p;
@@ -308,7 +325,7 @@ export async function createStage(host, cb = {}) {
308
325
  }
309
326
  function finishLeds(el) {
310
327
  if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
311
- else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
328
+ else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
312
329
  else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
313
330
  }
314
331
  function buildPlan(g) {
@@ -954,7 +971,7 @@ export async function createStage(host, cb = {}) {
954
971
  if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
955
972
  if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * (E.theme === 'light' ? 1.45 : 1.1) + .08 * c.sel) * ob * gf * c.vis; }
956
973
  if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
957
- if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
974
+ if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS', 'ACCEPTED'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
958
975
  if (el.inner) {
959
976
  const p = pr ? pr.p : 0, vis = p > .002;
960
977
  if (!vis && el.inner.visible) { E.release(el.inner); }
@@ -1239,6 +1256,7 @@ export async function createStage(host, cb = {}) {
1239
1256
  const lamps = { title: 'Lamps', rows: [
1240
1257
  { sw: { lamp: 'st-merged' }, label: 'merged · passed · accepted' }, { sw: { lamp: 'st-running' }, label: 'running · interrupted' },
1241
1258
  { sw: { lamp: 'st-fail' }, label: 'failed · conflict · infra · timeout' }, { sw: { lamp: 'st-pending' }, label: 'pending, not started' },
1259
+ { sw: { lamp: 'st-outdated' }, label: 'outdated review' },
1242
1260
  { sw: { route: true }, label: st.view === 'atlas' ? 'chosen run' : 'chosen task' + (L.route ? ' and its route' : '') }] };
1243
1261
  if (st.view === 'atlas') {
1244
1262
  out.push({ title: 'Map', rows: [], note: 'Each block is a run, each district a repository. The lamps on a block are its tasks; the block stands taller the more tasks it has.' }, lamps);
@@ -1318,8 +1336,7 @@ export async function createStage(host, cb = {}) {
1318
1336
  if (el.kind === 'task') {
1319
1337
  const t = byId.get(el.data.task.id); if (!t) continue;
1320
1338
  el.data.task = t; setStatus(el, taskStatus(t)); halo(el);
1321
- const rows = list(t.attempts).map(a => a.status);
1322
- if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
1339
+ const rows = attemptLamps(t);
1323
1340
  if (rows.length > el.ledCount) { /* more tries than lamps: the newest show */ }
1324
1341
  setLeds(el, rows.slice(-el.ledCount));
1325
1342
  const sig = JSON.stringify([t.status, t.findings, t.gate, list(t.shadow).map(s => s.agreement), t.dwarf, t.accepted_by, el.status]);
@@ -1385,7 +1402,7 @@ export async function createStage(host, cb = {}) {
1385
1402
  E.invalidate(100); // marks a replay reaches drop in
1386
1403
  st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
1387
1404
  for (const el of EL.values()) {
1388
- if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
1405
+ if (el.kind === 'task') { setStatus(el, taskStatus(el.data.task)); setLeds(el, attemptLamps(el.data.task).slice(-el.ledCount)); }
1389
1406
  else if (el.kind === 'finish') setStatus(el, finishStatus(el.id, finishInfo(el.id)));
1390
1407
  else if (el.data && el.data.item) setStatus(el, st.replay !== null ? replayItemStatus(el.data.item) : el.data.item.status);
1391
1408
  else continue;