@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
@@ -42,6 +42,21 @@ def _kind(path):
42
42
  return None
43
43
 
44
44
 
45
+ def _default_registry_dir():
46
+ """forge's run registry, resolved the way forge writes it (scripts/forge_registry.registry_dir).
47
+
48
+ ``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise ``~/.config``: an
49
+ empty or relative value counts as unset (XDG spec), as it does when forge registers a run.
50
+ None only when even that is not absolute (a relative HOME): forge never registers a run
51
+ there, so reading it would only pick up whatever the current directory happens to hold.
52
+ """
53
+ value = os.environ.get('XDG_CONFIG_HOME') or ''
54
+ base = Path(value).expanduser() if value else None
55
+ if base is None or not base.is_absolute():
56
+ base = Path.home() / '.config'
57
+ return base / 'forge' / 'runs' if base.is_absolute() else None
58
+
59
+
45
60
  def discover(plans=(), roots=(), registry=True, registry_dir=None):
46
61
  found, seen = [], set()
47
62
 
@@ -76,10 +91,9 @@ def discover(plans=(), roots=(), registry=True, registry_dir=None):
76
91
  for path in roots:
77
92
  scan(Path(path))
78
93
  if registry:
79
- base = Path(registry_dir) if registry_dir is not None else Path(
80
- os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'forge' / 'runs'
94
+ base = Path(registry_dir) if registry_dir is not None else _default_registry_dir()
81
95
  try:
82
- entries = sorted(base.glob('*.txt'))
96
+ entries = sorted(base.glob('*.txt')) if base is not None else []
83
97
  except OSError:
84
98
  entries = []
85
99
  for entry in entries:
@@ -310,10 +324,14 @@ def _tasks(reader):
310
324
  reader.warn(label, 'invalid task id')
311
325
  continue
312
326
  prefix = f'tasks/{ident}'
313
- status = 'MERGED' if reader.exists(prefix + '/merged') else reader.word(prefix + '/status') or 'PENDING'
327
+ # One stat: the run list needs the mtime, and detail reads the same marker again.
328
+ merged = reader.stat(prefix + '/merged')
329
+ status = 'MERGED' if merged is not None else reader.word(prefix + '/status') or 'PENDING'
314
330
  tasks.append(dict(id=ident, title='\t'.join(row[6:]), deps=_list(deps),
315
331
  difficulty=difficulty, files=_list(files), dwarf=_nullable(dwarf),
316
- qa=_nullable(qa), status=status))
332
+ qa=_nullable(qa), status=status,
333
+ merged_at=float(merged.st_mtime) if merged is not None else None,
334
+ accepted=_nonempty(reader, prefix + '/accepted.by')))
317
335
  return tasks
318
336
 
319
337
 
@@ -353,12 +371,15 @@ def _summary(reader, source):
353
371
  final_review = reader.word('final-review.verdict')
354
372
  dwarf_models = sorted({task['dwarf'] for task in tasks if task['dwarf']})
355
373
  qa_models = sorted({task['qa'] for task in tasks if task['qa']})
374
+ # Before snapshotting `updated`: these reads are files the summary read, so a newer
375
+ # accepted.integration (or the published verdict / review.meta) moves the activity time.
376
+ final_review_outdated = _published_outdated(reader, tasks)
356
377
  result.update(updated=reader.updated, created=_created(reader.path, _PLAN_START_FILES),
357
378
  task_count=len(tasks), counts=counts,
358
379
  task_statuses=[dict(id=task['id'], status=task['status'], difficulty=task['difficulty'] or None,
359
- title=_brief(task['title'])) for task in tasks],
380
+ title=_brief(task['title']), accepted=task['accepted']) for task in tasks],
360
381
  models=sorted(set(dwarf_models) | set(qa_models)), dwarf_models=dwarf_models, qa_models=qa_models,
361
- final_review=final_review)
382
+ final_review=final_review, final_review_outdated=final_review_outdated)
362
383
  return result, tasks
363
384
 
364
385
 
@@ -389,9 +410,10 @@ def _solo_summary(reader, source):
389
410
  decompose_level=None, verification_status=None, updated=reader.updated,
390
411
  created=_created(reader.path, _SOLO_START_FILES),
391
412
  task_count=1, counts={status: 1},
392
- task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run'))],
413
+ task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run'),
414
+ accepted=False)],
393
415
  models=sorted({dwarf, qa} - {None}), dwarf_models=[dwarf] if dwarf else [],
394
- qa_models=[qa] if qa else [], final_review=None)
416
+ qa_models=[qa] if qa else [], final_review=None, final_review_outdated=None)
395
417
 
396
418
 
397
419
  def summarize(path, source='plan') -> dict:
@@ -502,6 +524,26 @@ def _shadows(reader, ident, task_execs):
502
524
  return result
503
525
 
504
526
 
527
+ def _accepted_fields(reader, name, text):
528
+ """(attempt, over) from an accepted.by body. A non-integer attempt= is null and warned.
529
+
530
+ status= FAIL or UNKNOWN is what was accepted over; any other or missing value is null
531
+ (a PASS record is an acceptance with nothing to say beyond "accepted").
532
+ """
533
+ if not text:
534
+ return None, None
535
+ fields = executions.parse_resolved(text)
536
+ attempt = None
537
+ raw = fields.get('attempt')
538
+ if raw:
539
+ try:
540
+ attempt = int(raw)
541
+ except (ValueError, OverflowError):
542
+ reader.warn(name, 'attempt is not an integer')
543
+ status = fields.get('status')
544
+ return attempt, status if status in ('FAIL', 'UNKNOWN') else None
545
+
546
+
505
547
  def _task_detail(reader, task, run_id, waves, branches):
506
548
  """Fill in a plan task; returns every execution it read (its own and its shadows')."""
507
549
  ident = task['id']
@@ -520,6 +562,8 @@ def _task_detail(reader, task, run_id, waves, branches):
520
562
  task['qa_tail'] = reader.cap(qa, prefix + '/qa_tail', 4000, True)
521
563
  task['dwarf_tail'] = reader.cap(reader.text(prefix + '/dwarf.last'), prefix + '/dwarf_tail', 4000, True)
522
564
  task['accepted_by'] = reader.text(prefix + '/accepted.by')
565
+ task['accepted_attempt'], task['accepted_over'] = _accepted_fields(
566
+ reader, prefix + '/accepted.by', task['accepted_by'])
523
567
  for field, filename in [('prompt', 'prompt.md'), ('approach', 'approach.md'), ('retry_findings', 'retry_findings.md')]:
524
568
  task[field] = reader.cap(reader.text(prefix + '/' + filename), prefix + '/' + field)
525
569
  task['route'] = []
@@ -606,6 +650,120 @@ def _published(reader):
606
650
  return real if executions.inside(reader.path, real) else None
607
651
 
608
652
 
653
+ def _review_clock(reader, name):
654
+ """(source, at) of a `final-review-<e>-<p>` or `integrate-<e>-<p>` dir name, else None.
655
+
656
+ `at` is the whole-second epoch in the name. The same clock `executions.review_dirs` uses,
657
+ so a summary does not have to scan the plan to know when the published review started.
658
+ """
659
+ match = executions._KIND.fullmatch(name)
660
+ if not match:
661
+ return None
662
+ kind, epoch, _pid = match.groups()
663
+ if len(epoch) > 15:
664
+ reader.warn(name, 'invalid epoch')
665
+ return None
666
+ try:
667
+ at = float(epoch)
668
+ if not math.isfinite(at):
669
+ raise ValueError()
670
+ except (ValueError, OverflowError):
671
+ reader.warn(name, 'invalid epoch')
672
+ return None
673
+ return ('review' if kind == 'final-review' else 'integrate'), at
674
+
675
+
676
+ def _published_identity(reader):
677
+ """(source, at) of the dir `final-review` links to, when the link stays inside the plan."""
678
+ real = _published(reader)
679
+ if real is None:
680
+ return None
681
+ path = Path(real)
682
+ # An integrate review is `<integrate-dir>/final-review`; a final-review dir is named itself.
683
+ if path.name == 'final-review':
684
+ found = _review_clock(reader, path.parent.name)
685
+ return found if found is not None and found[0] == 'integrate' else None
686
+ found = _review_clock(reader, path.name)
687
+ return found if found is not None and found[0] == 'review' else None
688
+
689
+
690
+ def _merge_pairs(tasks):
691
+ """(id, merged_at) of every merged task, oldest merge first, then id."""
692
+ pairs = [(task['id'], task['merged_at']) for task in tasks if task.get('merged_at') is not None]
693
+ pairs.sort(key=lambda item: (item[1], item[0]))
694
+ return pairs
695
+
696
+
697
+ # What `git rev-parse` writes for a commit: sha1 (40) or sha256 (64), lowercase hex.
698
+ _COMMIT = re.compile(r'(?:[0-9a-f]{40}|[0-9a-f]{64})')
699
+
700
+
701
+ def _meta_integration(reader, name, meta):
702
+ """The `integration=` commit of a review.meta, or None.
703
+
704
+ Missing or empty is absent (older runs record no integration commit). Any other value
705
+ that is not a commit object name is malformed: null, and a warning, so freshness uses
706
+ the next rule instead of treating the text as the commit the review judged.
707
+ """
708
+ value = meta.get('integration') or None
709
+ if not value:
710
+ return None
711
+ if _COMMIT.fullmatch(value):
712
+ return value
713
+ reader.warn(name, 'integration is not a commit')
714
+ return None
715
+
716
+
717
+ def _review_freshness(accepted, integration, tip, source, at, merges, verdict):
718
+ """(outdated, merged_since) for one final review.
719
+
720
+ Null when the review has no verdict or there is no `accepted.integration` to compare with.
721
+ Otherwise the first rule that applies: a known `integration` commit, else `tip` when
722
+ `source` is `review`, else (an older integrate review) any task merged after `at`.
723
+ `integration` is a commit object name or None (a malformed value is None). A merge counts
724
+ when its marker mtime is `>= at + 1`. `merged_since` is those ids, oldest first, and is
725
+ empty unless the review is outdated.
726
+ """
727
+ later = []
728
+ if at is not None:
729
+ later = [item for item in merges if item[1] is not None and item[1] >= at + 1]
730
+ later.sort(key=lambda item: (item[1], item[0]))
731
+ ids = [task_id for task_id, _merged_at in later]
732
+ if not verdict or not accepted:
733
+ return None, []
734
+ if integration:
735
+ moved = integration != accepted
736
+ elif source == 'review':
737
+ moved = tip != accepted
738
+ else:
739
+ moved = bool(ids)
740
+ return (True, ids) if moved else (False, [])
741
+
742
+
743
+ def _stamp_review(summary, accepted, merges):
744
+ summary['outdated'], summary['merged_since'] = _review_freshness(
745
+ accepted, summary['integration'], summary['tip'], summary['source'], summary['at'], merges,
746
+ summary['verdict'])
747
+ return summary
748
+
749
+
750
+ def _published_outdated(reader, tasks):
751
+ """final_review_outdated for the published review only.
752
+
753
+ Reads `accepted.integration` and, through the `final-review` link, that dir's `verdict`
754
+ and `review.meta`. Does not scan other review dirs or open a review's attempts.
755
+ """
756
+ found = _published_identity(reader)
757
+ if found is None:
758
+ return None
759
+ source, at = found
760
+ verdict = reader.word('final-review/verdict')
761
+ meta = executions.parse_resolved(reader.text('final-review/review.meta'))
762
+ return _review_freshness(reader.line('accepted.integration'),
763
+ _meta_integration(reader, 'final-review/review.meta', meta),
764
+ meta.get('tip') or None, source, at, _merge_pairs(tasks), verdict)[0]
765
+
766
+
609
767
  def _final_review(reader, item, newest, published):
610
768
  """(FinalReviewSummary, its executions, review.meta) of one review dir."""
611
769
  name, source, at, rel = item
@@ -630,6 +788,8 @@ def _final_review(reader, item, newest, published):
630
788
  verdict=verdict, state=state, spec=meta.get('spec') or None,
631
789
  threshold=None if threshold == 'none' else threshold,
632
790
  base=meta.get('base') or None, tip=meta.get('tip') or None,
791
+ integration=_meta_integration(reader, rel + '/review.meta', meta),
792
+ outdated=None, merged_since=[],
633
793
  gate=_gate(reader, rel + '/qa.gate'), findings=executions.qa_result(reader, rel + '/qa.last')[1],
634
794
  execution=run['id'] if run else None, started=run['started'] if run else None,
635
795
  ended=run['ended'] if run else None, duration_s=run['duration_s'] if run else None)
@@ -701,6 +861,7 @@ def _solo_detail(reader, source):
701
861
  dwarf=_role_spec(reader, 'dwarf'), qa=_role_spec(reader, 'qa'), wave=1, status=status,
702
862
  branch=None, attempt=max((item['attempt'] for item in attempts), default=None),
703
863
  attempts=attempts, escalations=[], verdict=_verdict(qa), accepted_by=None,
864
+ accepted=False, accepted_attempt=None, accepted_over=None,
704
865
  explored=False, route=[], clef={'first_pass': None, 'retry_pass': {}}, shadow=[],
705
866
  prompt=reader.cap(reader.text('prompt.md'), 'tasks/solo/prompt'), approach=None,
706
867
  retry_findings=None, qa_tail=reader.cap(qa, 'tasks/solo/qa_tail', 4000, True),
@@ -767,6 +928,10 @@ def detail(path, source='plan') -> dict:
767
928
  result['timeline'] = _timeline(pool + run_execs, [item for item in run_execs if item['role'] == 'parallel'],
768
929
  [item for item in run_execs if item['role'] == 'verification'])
769
930
  result['integration'] = _integration(reader, tasks, integrations, reviews, published)
931
+ accepted = result['integration']['accepted'] if result['integration'] is not None else None
932
+ merges = [(item['id'], item['at']) for item in result['integration']['merged']] if result['integration'] else []
933
+ for summary in reviews:
934
+ _stamp_review(summary, accepted, merges)
770
935
  result['final_review'] = reviews[0] if reviews else None
771
936
  result['final_reviews'] = reviews
772
937
  result['deferrals'] = [line.strip() for line in (reader.text('deferrals.txt') or '').splitlines() if line.strip()]
@@ -943,6 +1108,7 @@ def final_review_detail(path, name, source='plan'):
943
1108
  if index is None:
944
1109
  return None
945
1110
  summary, _, meta = _final_review(reader, items[index], index == 0, _published(reader))
1111
+ _stamp_review(summary, reader.line('accepted.integration'), _merge_pairs(_tasks(reader)))
946
1112
  rel = items[index][3]
947
1113
  qa_last = reader.text(rel + '/qa.last')
948
1114
  result = dict(summary)
@@ -1215,8 +1381,13 @@ def run_findings(path, source='plan'):
1215
1381
  if kind is None:
1216
1382
  return None
1217
1383
  keys = ('kind', 'execution', 'attempt', 'spec', 'verdict')
1384
+ if kind == 'solo':
1385
+ task_ids, merges, accepted = ['solo'], [], None
1386
+ else:
1387
+ rows = _tasks(reader)
1388
+ task_ids, merges, accepted = [task['id'] for task in rows], _merge_pairs(rows), reader.line('accepted.integration')
1218
1389
  tasks = []
1219
- for task_id in ['solo'] if kind == 'solo' else [task['id'] for task in _tasks(reader)]:
1390
+ for task_id in task_ids:
1220
1391
  ctx = _diff_task(reader, task_id)
1221
1392
  reviews = [dict({key: review[key] for key in keys}, findings=found)
1222
1393
  for review in (ctx['reviews'] if ctx else []) for found in [_findings_of(reader, review)] if found]
@@ -1227,13 +1398,15 @@ def run_findings(path, source='plan'):
1227
1398
  items, published = executions.review_dirs(reader)[0], _published(reader)
1228
1399
  for index, item in enumerate(items[:_MAX_REVIEWS]):
1229
1400
  summary = _final_review(reader, item, index == 0, published)[0]
1401
+ _stamp_review(summary, accepted, merges)
1230
1402
  # the same review final_review_diff pins, so a Finding's id opens it there
1231
1403
  review = _diff_review('final', None, None, summary['spec'], summary['verdict'], item[3] + '/qa.last', [],
1232
1404
  summary['execution'])
1233
1405
  found = _findings_of(reader, review)
1234
1406
  if found:
1235
1407
  finals.append(dict(name=item[0], execution=summary['execution'], spec=summary['spec'],
1236
- verdict=summary['verdict'], findings=found))
1408
+ verdict=summary['verdict'], outdated=summary['outdated'],
1409
+ merged_since=summary['merged_since'], findings=found))
1237
1410
  return dict(run=run_key(reader.path), available=findings.available(), tasks=tasks, final_reviews=finals,
1238
1411
  truncated=reader.truncated, warnings=reader.warnings)
1239
1412
 
@@ -19,7 +19,7 @@ window.ForgeApp = (() => {
19
19
  replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
20
20
  lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
21
21
  settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
22
- models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
22
+ models: false, modelsData: null, modelsText: null, modelsError: null, reviewerOutcomes: null, modelsPrefs: store.get('models', {}),
23
23
  files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
24
24
  findings: new Map() };
25
25
  state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
@@ -106,9 +106,9 @@ window.ForgeApp = (() => {
106
106
  function link(text, key, sel) { const a = el('a', text); a.href = hashFor(key, sel); return a; }
107
107
 
108
108
  const statuses = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra',
109
- 'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict']);
110
- function chip(value) {
111
- const status = String(value || 'unknown').toLowerCase();
109
+ 'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict', 'accepted', 'outdated']);
110
+ function chip(value, cls) {
111
+ const status = String(cls || value || 'unknown').toLowerCase();
112
112
  return el('span', value || '—', 'chip st-' + (statuses.has(status) ? status : 'unknown'));
113
113
  }
114
114
  function time(value) {
@@ -251,7 +251,8 @@ window.ForgeApp = (() => {
251
251
  if (!solo) {
252
252
  const verification = el('span', undefined, 'verification');
253
253
  verification.append(document.createTextNode('Verification '), chip(run.verification_status));
254
- if (run.final_review) verification.append(document.createTextNode(' Review '), chip(run.final_review));
254
+ const reviewStatus = RM.runReviewStatus(run);
255
+ if (reviewStatus) verification.append(document.createTextNode(' Review '), chip(reviewStatus));
255
256
  item.append(verification);
256
257
  }
257
258
  $('run-list').append(item);
@@ -338,7 +339,7 @@ window.ForgeApp = (() => {
338
339
  add('Kind', sel('kind', [['', 'any'], ['plan', 'plan'], ['solo', 'solo']]));
339
340
  add('Model', sel('model', [['', 'any'], ...opts.models.map(m => [m, m])]));
340
341
  add('Age', sel('ageDays', [[0, 'any'], [1, 'last day'], [7, 'last week'], [30, 'last month']]));
341
- add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']]));
342
+ add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']]));
342
343
  const all = el('input'); all.type = 'checkbox'; all.checked = state.showAll; all.dataset.f = 'showAll';
343
344
  const allL = el('label', undefined, 'filter check'); allL.append(all, document.createTextNode(' older runs too')); bar.append(allL);
344
345
  const reset = el('button', 'Reset'); reset.type = 'button'; reset.addEventListener('click', () => { Object.assign(state.mapFilter, { text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }); state.showAll = false; filterChanged(true); });
@@ -368,7 +369,12 @@ window.ForgeApp = (() => {
368
369
  card.className = `map-card st-${({ live: 'running', failed: 'fail', done: 'merged', pending: 'pending' })[m.state]}${m.match ? '' : ' dim'}`;
369
370
  card.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
370
371
  const leds = el('span', undefined, 'leds');
371
- for (const t of m.tasks.slice(0, 60)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}`; leds.append(l); }
372
+ for (const t of m.tasks.slice(0, 60)) {
373
+ const status = RM.taskStatus(t);
374
+ const l = el('i', undefined, 'led st-' + String(status || 'pending').toLowerCase());
375
+ l.title = `${t.id}: ${status}`;
376
+ leds.append(l);
377
+ }
372
378
  card.append(leds, el('span', relative(r.updated), 'muted'));
373
379
  grid.append(card);
374
380
  }
@@ -474,15 +480,23 @@ window.ForgeApp = (() => {
474
480
  runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
475
481
  savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
476
482
  tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
477
- modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
483
+ modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias), reviewerOutcomes: state.reviewerOutcomes,
478
484
  });
479
485
  }
486
+ // How each reviewer's PASSed work held up (forge dataset reviewers). Advisory and optional: read once per
487
+ // change of the models data, and a failure (an export, no dataset) only leaves the column empty.
488
+ function loadReviewerOutcomes() {
489
+ if (SNAP) return;
490
+ fetchJSON('/api/models/reviewers').then(result => { state.reviewerOutcomes = result.data; },
491
+ () => { state.reviewerOutcomes = null; }).then(() => { if (state.models) renderModels(); });
492
+ }
480
493
  function loadModels() {
481
494
  return request('/api/models', result => {
482
495
  state.modelsError = null;
483
496
  if (result.text === state.modelsText) return;
484
497
  state.modelsText = result.text; state.modelsData = result.data;
485
498
  if (state.models) renderModels();
499
+ loadReviewerOutcomes();
486
500
  }, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
487
501
  }
488
502
 
@@ -741,11 +755,11 @@ window.ForgeApp = (() => {
741
755
  { id: 'title', label: 'Title', value: t => t.title },
742
756
  { id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
743
757
  { id: 'wave', label: 'Wave', value: t => Number.isFinite(Number(t.wave)) && t.wave !== null && t.wave !== '' ? Number(t.wave) : null, cell: t => display(t.wave) },
744
- { id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
758
+ { id: 'status', label: 'Status', value: t => RM.taskStatus(t), cell: t => chip(RM.taskStatus(t)) },
745
759
  { id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
746
760
  { id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
747
761
  { id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
748
- { id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
762
+ { id: 'verdict', label: 'Verdict', value: t => RM.taskVerdict(t).text, cell: t => { const v = RM.taskVerdict(t); return chip(v.text, v.status); } },
749
763
  { id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
750
764
  value: t => t.findings && t.findings.worst ? SEV_RANK[t.findings.worst] * 1000 - (t.findings[t.findings.worst] || 0) : t.findings && t.findings.plausible ? 4000 : null,
751
765
  cell: t => { const f = t.findings, v = sev(f); if (!f || !f.worst) return v; const n = el('span', v, 'sev-chip sev-' + f.worst); return n; } },
@@ -427,9 +427,12 @@ export function createCloud(E, host, cb = {}) {
427
427
  function info(el) {
428
428
  const f = el.f, r = f.run, counts = Object.entries(r.counts || {}).map(([s, n]) => `${s.toLowerCase()} ${n}`).join(' · ');
429
429
  const proj = st.L && st.L.projects.find(p => p.id === f.project);
430
+ const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
431
+ const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
432
+ const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : null;
430
433
  return [['meta', `${proj ? proj.name : RM.basename(r.repo)} · ${r.run_id || ''} · ${r.kind}`], ['title', String(r.goal || 'Untitled run').split('\n')[0]],
431
434
  ['body', `${f.n} task${f.n > 1 ? 's' : ''} · ${counts}`], ['body', `${[...f.dwarf].join(', ') || '—'} → ${[...f.qa].join(', ') || '—'}`],
432
- ['foot', `started ${new Date(f.t * 1000).toLocaleString()}${r.final_review ? ' · review ' + r.final_review : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
435
+ ['foot', `started ${new Date(f.t * 1000).toLocaleString()}${reviewText ? ' · review ' + reviewText : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
433
436
  ['foot', (store.layouts[st.layout] || {})[el.key] ? 'moved by you · drag to move, Shift = depth, Alt = height' : 'drag to move · double-click to open']];
434
437
  }
435
438
  function showTip(el, cx, cy) {
@@ -63,7 +63,7 @@
63
63
  const aliases = specs => new Set(list(specs).map(RM.alias).filter(Boolean));
64
64
  return { key: run.key, run, project: hashId(run.repo || '?'), n, bucket: bucket(n), diff, dominant, state, t: startOf(run),
65
65
  dwarf: aliases(run.dwarf_models || run.models), qa: aliases(run.qa_models || []), all: aliases(run.models),
66
- review: run.final_review || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
66
+ review: RM.runReviewStatus(run) || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
67
67
  }
68
68
 
69
69
  /* ---------- filters: the cloud shows exactly the runs that match ---------- */
@@ -254,7 +254,7 @@ window.ForgeCloudUI = (() => {
254
254
  }
255
255
  const more = el('fieldset', undefined, 'cloud-field cloud-more'); more.append(el('legend', 'More'));
256
256
  more.append(select('Active', 'active', [[0, 'any time'], [1, 'in the last day'], [7, 'in the last week'], [30, 'in the last month'], [90, 'in the last 3 months']], () => f.activeDays, v => { f.activeDays = Number(v) || 0; }));
257
- more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
257
+ more.append(select('Final review', 'review', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['OUTDATED', 'outdated'], ['none', 'no verdict']], () => f.review, v => { f.review = v; }));
258
258
  more.append(select('Verification', 'verification', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'not run']], () => f.verification, v => { f.verification = v; }));
259
259
  const dec = Object.keys(O.decompose);
260
260
  if (dec.length > 1) more.append(chips('Decompose', dec.map(k => [k, k === 'none' ? 'off' : k, O.decompose[k]]), () => f.decompose, v => { f.decompose = v; }));
@@ -290,13 +290,22 @@ window.ForgeCloudUI = (() => {
290
290
  head.append(shut);
291
291
  card.append(head, el('h2', String(r.goal || 'Untitled run').split('\n')[0]), el('p', r.run_id || '', 'mono muted'));
292
292
  const leds = el('span', undefined, 'leds');
293
- for (const t of list(r.task_statuses).slice(0, 120)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}${t.title ? ' · ' + t.title : ''}`; leds.append(l); }
293
+ for (const t of list(r.task_statuses).slice(0, 120)) {
294
+ const st = RM.taskStatus(t);
295
+ const l = el('i', undefined, 'led st-' + String(st || 'pending').toLowerCase());
296
+ l.title = `${t.id}: ${st}${t.title ? ' · ' + t.title : ''}`;
297
+ leds.append(l);
298
+ }
294
299
  card.append(leds);
295
300
  const dl = el('dl', undefined, 'fields');
296
301
  const mix = ['high', 'medium', 'low', 'none'].filter(k => f.diff[k]).map(k => `${f.diff[k]} ${k === 'none' ? 'unset' : k}`).join(' · ');
297
302
  const plates = specs => { if (!specs.length || !window.ForgeIcons) return specs.join(', ') || '—'; const s = el('span', undefined, 'plates'); for (const x of specs) s.append(window.ForgeIcons.modelPlate(x)); return s; };
303
+ const verdict = r.final_review && typeof r.final_review === 'object' ? r.final_review.verdict : r.final_review;
304
+ const isOutdated = verdict && RM.runReviewStatus(r) === 'OUTDATED';
305
+ const reviewText = verdict ? (isOutdated ? `${verdict} (outdated)` : verdict) : '—';
306
+ const reviewVal = isOutdated ? el('span', reviewText, 'chip st-outdated') : reviewText;
298
307
  for (const [k, v, mono] of [['Tasks', `${f.n}${mix ? ' · ' + mix : ''}`], ['Implementers', plates([...f.dwarf])], ['Reviewers', plates([...f.qa])],
299
- ['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', r.final_review || '—'], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
308
+ ['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', reviewVal], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
300
309
  const dd = el('dd', undefined, mono ? 'mono' : ''); dd.append(v instanceof Node ? v : document.createTextNode(String(v))); dl.append(el('dt', k), dd);
301
310
  }
302
311
  card.append(dl);
@@ -345,7 +354,7 @@ window.ForgeCloudUI = (() => {
345
354
  a.href = '#/runs/' + encodeURIComponent(r.key);
346
355
  a.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''} · ${f.n} task${f.n === 1 ? '' : 's'}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
347
356
  const leds = el('span', undefined, 'leds');
348
- for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()));
357
+ for (const t of list(r.task_statuses).slice(0, 60)) leds.append(el('i', undefined, 'led st-' + String(RM.taskStatus(t) || 'pending').toLowerCase()));
349
358
  a.append(leds); grid.append(a);
350
359
  }
351
360
  g.append(grid); listHost.append(g);