@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
@@ -49,15 +49,19 @@ def registry_dir() -> Path:
49
49
  """The ``runs`` directory under forge's config home.
50
50
 
51
51
  Resolved the same way ``forge_jev.config_path()`` resolves ``jev.json``:
52
- ``XDG_CONFIG_HOME`` when set and not empty, otherwise ``~/.config``, then
53
- ``expanduser()``. An empty value counts as unset, as the XDG spec says and as
54
- forge-ui's run list reads it; taken literally it named a directory below the
55
- current one, usually the user's repo. ``config_path()`` is
56
- ``<that>/forge/jev.json``; the registry is the sibling ``runs`` directory.
52
+ ``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise
53
+ ``~/.config``. An empty or relative value counts as unset, as the XDG spec says
54
+ ("If an implementation encounters a relative path in any of these variables it
55
+ should consider the path invalid and ignore it") and as forge-ui's run list reads
56
+ it; taken literally it named a directory below the current one, usually the
57
+ user's repo. ``config_path()`` is ``<that>/forge/jev.json``; the registry is the
58
+ sibling ``runs`` directory.
57
59
  """
58
- config = (Path(os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')).expanduser()
59
- / 'forge/jev.json')
60
- return config.parent / 'runs'
60
+ value = os.environ.get('XDG_CONFIG_HOME') or ''
61
+ base = Path(value).expanduser() if value else None
62
+ if base is None or not base.is_absolute():
63
+ base = Path.home() / '.config'
64
+ return base / 'forge' / 'runs'
61
65
 
62
66
 
63
67
  def repo_key(repo) -> str:
@@ -118,8 +122,9 @@ def register_run_dir(repo: Path, run_dir: Path) -> None:
118
122
  it went to.
119
123
  """
120
124
  try:
121
- # A relative XDG_CONFIG_HOME resolves below the current directory -- for a runner or
122
- # `forge ui open`, usually the user's repo. Forge does not write into the user's tree.
125
+ # A relative HOME (registry_dir already ignores a relative XDG_CONFIG_HOME) would resolve
126
+ # below the current directory -- for a runner, usually the user's repo. Forge does not
127
+ # write into the user's tree.
123
128
  if not registry_dir().is_absolute():
124
129
  return
125
130
  registry = runs_registry(repo)
package/ui/README.md CHANGED
@@ -96,7 +96,7 @@ Sources are checked in this order:
96
96
  2. `--root DIR` itself, its children and grandchildren. Scanning stops when a plan
97
97
  directory is found; hidden children and symlinked child directories are skipped.
98
98
  3. Forge's registry: `*.txt` files in `$XDG_CONFIG_HOME/forge/runs/`, defaulting
99
- to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset or empty. Each file
99
+ to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset, empty or relative. Each file
100
100
  supplies absolute paths, one per line; blank lines and `#` comments are ignored.
101
101
  `--registry-dir` replaces this location; `--no-registry` disables it.
102
102
 
@@ -189,8 +189,9 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
189
189
  - **Models (`#/models`):** a leaderboard of the models forge used. Implementers: tasks, first-attempt
190
190
  pass rate with its 95% Wilson interval, censored attempts, retries, escalations away from the
191
191
  model, timeouts, gate and human accepts, median model time and tokens. Reviewers: primary,
192
- shadow and final reviews, the PASS/FAIL split, findings per review by severity, and how often a
193
- shadow review agreed with the primary. Filter by project, difficulty, kind and period, or keep
192
+ shadow and final reviews, the PASS/FAIL split, findings per review by severity, how often a
193
+ shadow review agreed with the primary, and how often work it PASSed did not hold (later
194
+ reverted or rewritten, from `forge dataset reviewers`; "not enough data" below 10 outcomes). Filter by project, difficulty, kind and period, or keep
194
195
  only exploratory picks; open a difficulty for its own table. Routing is not random, so the
195
196
  numbers compare what happened, not what would have: the page says so. A model's name opens the
196
197
  run cloud filtered to its runs.
@@ -265,7 +266,7 @@ takes effect. The routes are in [docs/api.md](docs/api.md).
265
266
  infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
266
267
  exempts it from the run's threshold), its own retry budget and a **model pin**: the model
267
268
  its next full retry uses, once. A control that cannot apply (no live scheduler, a merged
268
- task, a Fractal plan's pin) is disabled and says why.
269
+ task) is disabled and says why. A Fractal plan's pin control is hidden.
269
270
  - **Open findings (`forge issues`):** the findings a run, or every run of its repository,
270
271
  left open. The list opens from a quick offline listing and then fills in what is already
271
272
  filed; select findings to file as GitHub issues, or dismiss them (and undo that).
package/ui/docs/api.md CHANGED
@@ -50,7 +50,7 @@ written), and the reader must never raise because of one.
50
50
  | `deferrals.txt` | one line per task the wave planner deferred |
51
51
  | `integration.base`, `accepted.integration` | one commit each: where the integration branch started, the revision the run accepted |
52
52
  | `attempts/<role>-<uuid>/` | one *execution* (below) of the run itself: `parallel` (a `run`) or `verification` |
53
- | `final-review-<epoch>-<pid>/` | one whole-run review: `qa.input`, `qa.last`, `verdict` (`PASS FAIL UNKNOWN INVALIDATED ACCEPTED`; absent while it runs or when nothing was judged), `qa.infra`, `qa.gate`, `known_issues.md`, `review.meta` (`key=value`: `base tip source spec threshold`, `threshold=none` when unset), `tasks.summary`, `attempts/qa-<uuid>/` |
53
+ | `final-review-<epoch>-<pid>/` | one whole-run review: `qa.input`, `qa.last`, `verdict` (`PASS FAIL UNKNOWN INVALIDATED ACCEPTED`; absent while it runs or when nothing was judged), `qa.infra`, `qa.gate`, `known_issues.md`, `review.meta` (`key=value`: `base tip source spec threshold integration`, `threshold=none` when unset; `integration` is the integration-branch commit the reviewed result contains and is absent on older runs), `tasks.summary`, `attempts/qa-<uuid>/` |
54
54
  | `final-review` | a symlink to the newest *published* review dir; `final-review.verdict` a copy of its verdict |
55
55
  | `integrate-<epoch>-<pid>/` | one `integrate`: `setup.log`, `merge.log`, `verification.*`, `attempts/verification-<uuid>/`, and `final-review/` (a review dir as above) when it ran one |
56
56
  | `tasks/<id>/merged` | exists when the task was merged; its mtime is when |
@@ -60,7 +60,7 @@ written), and the reader must never raise because of one.
60
60
  | `tasks/<id>/escalations.tsv` | `epoch<TAB>attempt<TAB>from_spec<TAB>to_spec[<TAB>source]` per line; `source` (who decided: `policy`, a human's `retry --dwarf` as `retry`, or a `pin`) is missing on older rows, which read as `policy` |
61
61
  | `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
62
62
  | `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
63
- | `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value` lines in current forge; returned as text) |
63
+ | `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value`: `task`, `status`, `reason`, `by`, `at`, `epoch`, `attempt`, `reviewed_commit`; older runs are free text; returned as text) |
64
64
  | `tasks/<id>/qa.gate` | `threshold<TAB>decision<TAB>worst<TAB>n_confirmed<TAB>n_unlabelled<TAB>n_below` (`decision` is `accept` or `keep`, `-` for none) |
65
65
  | `tasks/<id>/known_issues.md` | the confirmed findings a threshold tolerated |
66
66
  | `tasks/<id>/dwarf.input`, `qa.input` | the prompts forge assembled; `<role>.delivered`, `<role>.resolved` the live dispatch files |
@@ -233,6 +233,7 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
233
233
  | `POST /api/runs/<key>/issues` | file GitHub issues for chosen findings; authenticated, 300 s timeout |
234
234
  | `POST /api/runs/<key>/issues/dismiss` | dismiss or (`undo`) restore findings; authenticated |
235
235
  | `POST /api/runs/<key>/delete` | dry-run or delete a run with `forge runs rm`; authenticated, 900 s timeout; later requests for the key answer `404` |
236
+ | `GET /api/models/reviewers` | `200` the `forge dataset reviewers --json` report (how each reviewer's PASSed work held up); no parameters |
236
237
  | `GET /api/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
237
238
  | `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
238
239
  | `GET /api/runs/<key>/export?texts=0\|1&paths=0\|1` | `200` the run as one HTML file (see Export), `Content-Disposition: attachment`, `Content-Security-Policy: default-src 'none'; sandbox`; `404` for an unknown run |
@@ -286,18 +287,20 @@ nothing when there is none. Every other response keeps
286
287
  "counts": {"MERGED": 3, "RUNNING": 1, "PENDING": 1},
287
288
  "verification_status": "PASS",
288
289
  "created": 1760619000.0,
289
- "task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs"},
290
- {"id": "page", "status": "RUNNING", "difficulty": "medium", "title": "Draw the page"}],
290
+ "task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs", "accepted": true},
291
+ {"id": "page", "status": "RUNNING", "difficulty": "medium", "title": "Draw the page", "accepted": false}],
291
292
  "models": ["opus", "sol:xhigh"],
292
293
  "dwarf_models": ["sol:xhigh"],
293
294
  "qa_models": ["opus"],
294
- "final_review": "FAIL"
295
+ "final_review": "FAIL",
296
+ "final_review_outdated": false
295
297
  }
296
298
  ```
297
299
 
298
- - `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty) and
299
- `title` (its first 160 characters, then `…`). A solo run: `[{"id": "solo", "status": <solo status>,
300
- "difficulty": null, "title": <goal or "Solo run">}]`.
300
+ - `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty),
301
+ `title` (its first 160 characters, then `…`) and `accepted` (`true` when `tasks/<id>/accepted.by`
302
+ is a non-empty file). A solo run: `[{"id": "solo", "status": <solo status>, "difficulty": null,
303
+ "title": <goal or "Solo run">, "accepted": false}]`. `accepted` does not change `status` or `counts`.
301
304
  - `created`: when the run began: the earliest birth time (else mtime) of the files a run writes
302
305
  once at its start (plan: `run_id`, `repo`, `request.md`, `goal.txt`, `tasks.tsv`; solo: `prompt.md`,
303
306
  `repo`, `request.md`, `goal.txt`), else `null`. These are stat'ed only, and never move `updated`.
@@ -306,6 +309,11 @@ nothing when there is none. Every other response keeps
306
309
  and `qa` specs as its task has them), nulls left out.
307
310
  - `final_review`: the first word of `final-review.verdict`, else `null`; always `null` for a
308
311
  solo run. (RunDetail replaces it with an object, below.)
312
+ - `final_review_outdated`: `true`, `false` or `null`, for the published review only (the dir the
313
+ `final-review` link points to, when that link stays inside the plan). The same rule as
314
+ FinalReviewSummary `outdated`, below. `null` when there is no such review, it has no verdict, or
315
+ `accepted.integration` is missing; always `null` for a solo run. RunDetail keeps this field
316
+ (it is not replaced by the newest review's `outdated`).
309
317
 
310
318
  `kind` is `"plan"` or `"solo"`. `updated` is the newest mtime (epoch seconds, float) among
311
319
  the run dir and the files the reader read. Any field it cannot determine is `null`
@@ -316,7 +324,11 @@ are `null`, `task_count` is 1, and `counts` is `{<status>: 1}` using the solo st
316
324
  The summary reads only `repo`, `goal.txt`, `status`, `verdict`, `dwarf.spec`, `qa.spec` and,
317
325
  when a spec file is missing, `*.resolved`. It also stats `dwarf.log` and `qa.log` without
318
326
  reading them, so `updated` moves while a model is writing a log. A plan summary reads
319
- `final-review.verdict` besides the files it always read.
327
+ `final-review.verdict` besides the files it always read. For `final_review_outdated` it also
328
+ reads `accepted.integration` and, through the `final-review` link, that dir's `verdict` and
329
+ `review.meta`, and stats each task's `merged` marker. Those reads count toward `updated`, the
330
+ same way as every other file the summary reads, so a newer `accepted.integration` moves the
331
+ run's activity time. It does not open a review's attempts or scan the other review dirs.
320
332
 
321
333
  ## RunDetail
322
334
 
@@ -370,7 +382,9 @@ Every RunSummary field, plus:
370
382
  path of its review dir or `null`, `ended` the latest end of its verification and review
371
383
  executions (`null` while one is unfinished or there is none).
372
384
  - `final_review` is the newest FinalReviewSummary (review and integrate dirs together, by the
373
- epoch in the name), or `null`; `final_reviews` lists the newest 20, newest first.
385
+ epoch in the name), or `null`; `final_reviews` lists the newest 20, newest first. That newest
386
+ review's `outdated` is its own. `final_review_outdated` stays the published review's flag, so
387
+ the two differ when a newer review exists than the one `final-review` links to.
374
388
  - `deferrals`: the non-empty lines of `deferrals.txt`, `[]` when it is missing.
375
389
  - `findings_available`: whether `scripts/forge-contract.py` could be loaded.
376
390
  - `fractal`: `null` without `fractal-selection.json`, else `{"enabled", "run", "available", "reason"}`
@@ -403,6 +417,9 @@ Every RunSummary field, plus:
403
417
  "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
404
418
  "verdict": "PASS",
405
419
  "accepted_by": null,
420
+ "accepted": false,
421
+ "accepted_attempt": null,
422
+ "accepted_over": null,
406
423
  "explored": true,
407
424
  "route": [{"at": 1760619000.0, "task": "reader", "tier": "high", "epsilon": 0.5,
408
425
  "candidates": ["astra", "opus"], "default": "opus", "chosen": "astra",
@@ -437,6 +454,14 @@ Every RunSummary field, plus:
437
454
  - `infra` in an attempt: `null` for empty, `-` or missing.
438
455
  - `source` in an escalation: the 5th column of `escalations.tsv` (`policy`, `retry` or `pin`, as forge wrote it); `policy` when the row has only 4 columns or the cell is empty.
439
456
  - `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
457
+ - `accepted`: `true` when `accepted.by` is a non-empty file, on `task_statuses` and on the task.
458
+ `status` stays what the merged marker or `status` file says (`MERGED` when the marker is there)
459
+ and `counts` do not change.
460
+ - `accepted_attempt`: the integer `attempt=` of `accepted.by`, or `null` when the key is missing
461
+ or not an integer (not an integer is warned). On the task, not on `task_statuses`.
462
+ - `accepted_over`: `"FAIL"` or `"UNKNOWN"` when `status=` in `accepted.by` says so; `null` for any
463
+ other or missing value, including `PASS` (the panel then says plain "accepted"). On the task,
464
+ not on `task_statuses`. A free-text `accepted.by` from an older forge has both null.
440
465
  - `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
441
466
  plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
442
467
  Capped like `qa_tail`.
@@ -468,8 +493,9 @@ it is missing or `<harness default>`). `wave` is 1, `branch` is `null`, and `att
468
493
  from the run dir's `attempts.tsv`. `status` is the solo status; `attempt` is the highest `n`
469
494
  in `attempts.tsv`, or `null`. `escalations`, `route` and `shadow` are empty. `verdict` and
470
495
  `qa_tail` come from `qa.last`, and `prompt` comes from `prompt.md`. `deps` and `files` are
471
- `[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `explored` is
472
- false; `clef` is `{first_pass: null, retry_pass: {}}`. `request` on the detail is `request.md`.
496
+ `[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `accepted` is
497
+ false; `accepted_attempt` and `accepted_over` are `null`; `explored` is false; `clef` is
498
+ `{first_pass: null, retry_pass: {}}`. `request` on the detail is `request.md`.
473
499
 
474
500
  ## Execution
475
501
 
@@ -538,6 +564,7 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
538
564
  ```json
539
565
  {"name": "final-review-1760622000-4242", "source": "review", "at": 1760622000.0, "published": true,
540
566
  "verdict": "FAIL", "state": "done", "spec": "opus", "threshold": "P2", "base": "1a2b...", "tip": "3c4d...",
567
+ "integration": "3c4d...", "outdated": false, "merged_since": [],
541
568
  "gate": {...}, "findings": FindingsSummary, "execution": "qa-...",
542
569
  "started": 1760622010.0, "ended": 1760622400.0, "duration_s": 390.0}
543
570
  ```
@@ -548,7 +575,29 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
548
575
  publishes nothing).
549
576
  - `state`: `done` with a verdict, `infra` with `qa.infra`, `running` when it is the newest
550
577
  review and has `qa.input`, else `orphaned`.
551
- - `spec`, `threshold`, `base`, `tip` come from `review.meta` (`threshold=none` is `null`).
578
+ - `spec`, `threshold`, `base`, `tip`, `integration` come from `review.meta` (`threshold=none` is
579
+ `null`). `integration` is the integration-branch commit the reviewed result contains, or `null`
580
+ when missing, empty, or not a commit. A commit is the object name `git rev-parse` writes: 40
581
+ or 64 lowercase hex digits. Any other `integration=` value is warned on that
582
+ `review.meta` (`integration is not a commit`; the summary's read of the published dir
583
+ warns `final-review/review.meta`) and is not a known commit, so freshness uses the next rule.
584
+ `source` on this object is the dir kind, `review` or `integrate`, not `review.meta`'s
585
+ `source=` (a worktree path).
586
+ - `outdated`: whether the plan's integration branch has moved since this review. `null` when the
587
+ review has no verdict, or when `accepted.integration` is missing. Otherwise the first rule that
588
+ applies:
589
+ 1. `integration` is a commit: `true` when it differs from `accepted.integration`.
590
+ 2. Else, for `source` `review`: `true` when `tip` differs from `accepted.integration`.
591
+ 3. Else (an integrate review from an older forge, which has no `integration=` commit): `true`
592
+ when any task merged after the review started.
593
+ - `merged_since`: the ids of tasks merged after the review started, oldest merge first. `[]` when
594
+ the review is not outdated. It may also be `[]` for an outdated review whose only later merge
595
+ fell inside the review's starting second.
596
+ - A task merged after the review when the mtime of its `merged` marker is `>= at + 1`, where `at`
597
+ is the whole-second epoch in the review dir's name. A merge inside that starting second is
598
+ ambiguous and is not counted. The execution's `started` is not used.
599
+ - `outdated` means tasks merged into the plan's integration branch since the review. Changes on
600
+ the user's own branch after an integrate are not tracked.
552
601
  - `execution` is its newest `attempts/qa-*`; `started`, `ended`, `duration_s` are that
553
602
  execution's.
554
603
 
@@ -662,13 +711,15 @@ lists, for all tasks and final reviews at once, without `text` or `line`.
662
711
  "verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
663
712
  "location": "ui/reader.py:42", "summary": "…"}]}]}],
664
713
  "final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
714
+ "outdated": true, "merged_since": ["reader"],
665
715
  "findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
666
716
  "truncated": [], "warnings": []}
667
717
  ```
668
718
 
669
719
  A task's `reviews` are every primary and shadow review of every attempt that listed a Finding
670
- (a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did. A
671
- Finding's `id` is `<execution>:<n>`, `n` its 0-based place in the reply (`primary:<n>` or
720
+ (a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did. Each
721
+ of those carries `outdated` and `merged_since`, the same values as that review's
722
+ FinalReviewSummary. A Finding's `id` is `<execution>:<n>`, `n` its 0-based place in the reply (`primary:<n>` or
672
723
  `final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
673
724
  `id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
674
725
  contract module, and the lists are then empty.
@@ -961,9 +1012,12 @@ of the other mode: it replaces it as it does one running other code. `forge ui s
961
1012
  mode (`read_only` in its JSON).
962
1013
 
963
1014
  **The CLIs' configuration.** The server `forge ui open` starts runs without any `FORGE_*` variable
964
- of the shell it was started from. The CLIs its writes call still need the forge configuration, so
1015
+ of the shell it was started from. The CLIs it calls, for reads and writes alike, still need the forge configuration, so
965
1016
  `open` hands them these, as that shell has them (an empty value counts as unset; a relative
966
- `FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory):
1017
+ `FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory, except that a
1018
+ `FORGE_DATA_HOME` starting with `~` is kept as given, because its reader expands the `~`; a
1019
+ `FORGE_REGISTRY` starting with `~` is not expanded by anything, so it is made absolute like any other
1020
+ relative path):
967
1021
  `FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
968
1022
  `XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
969
1023
  state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
@@ -971,12 +1025,14 @@ call gets them on top of the server's own environment; the server's environment
971
1025
  changed. No other variable is passed, and none of these is a secret. `server.json` records them as
972
1026
  `cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
973
1027
 
974
- `open` never reuses a server whose recorded `cli_env` differs from the current shell's. It names
975
- each variable that differs and both values, says to run `forge ui stop` and then open again, exits
976
- `5`, prints no link and leaves the server running (it may be in the middle of another shell's
977
- write). A server
978
- running other code, in the other mode or reading another registry directory is still replaced, and
979
- the new one gets the current shell's configuration.
1028
+ `open` never reuses a server whose recorded `cli_env` differs from the current shell's. For a
1029
+ server that takes writes it names each variable that differs and both values, says to run
1030
+ `forge ui stop` and then open again, exits `5`, prints no link and leaves the server running (it may
1031
+ be in the middle of another shell's write). A read-only server (`FORGE_UI_READONLY=1`, asked for
1032
+ again in the same mode) has no write to cut off, so it is replaced, as is one running other code, in
1033
+ the other mode or reading another registry directory: the new one gets the current shell's
1034
+ configuration, `open` prints the link and exits `0`. Read-only servers still run CLIs for their GET
1035
+ routes, so their configuration matters too.
980
1036
 
981
1037
  **Serialization.** One write per run key at a time. A second write to a run whose write is still
982
1038
  running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
@@ -1061,6 +1117,15 @@ deletion) `500`. Deletion holds the run's write lock and a server-wide lock on t
1061
1117
  directory (`409 busy` when either is held). The run key is looked up again under the lock, and
1062
1118
  once the directory is gone every later request for that key is `404`. Timeout 900 s.
1063
1119
 
1120
+ ### `GET /api/models/reviewers`
1121
+
1122
+ Runs `dataset reviewers --json` and answers with its object: `horizon_days`, `min_landed`,
1123
+ `survives_at`, `reviewers` (per reviewer alias: `landed`, `reverted`, `rewritten`,
1124
+ `did_not_hold`, `enough`, and `rate` and `ci` only when `enough`, else `null` with a `note`),
1125
+ `unattributed`, `skipped` and `other_horizons`. Any query parameter is a `400`. Not cached: every
1126
+ request runs the CLI, and the page asks once per change of `/api/models`. Advisory: it feeds the
1127
+ Reviewers table's Did not hold column and nothing else. Timeout 60 s.
1128
+
1064
1129
  ### `GET /api/config?run=<key>` and `POST /api/config`
1065
1130
 
1066
1131
  The repository is the run's own `repo` file (first line, an existing absolute directory; else
@@ -43,6 +43,7 @@ _EXITS = {2: (400, 'invalid'), 3: (409, 'refused'), 4: (500, 'failed')}
43
43
  # of them is a secret (forge-config.py's SECRET_WORDS), and no other FORGE_* variable is ever passed.
44
44
  CLI_ENV_NAMES = ('FORGE_REGISTRY', 'FORGE_DATA_HOME', 'FORGE_DATASET', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME')
45
45
  _CLI_ENV_PATHS = ('FORGE_REGISTRY', 'FORGE_DATA_HOME') # read relative to the shell's directory, not the server's
46
+ _CLI_ENV_EXPANDS_HOME = ('FORGE_DATA_HOME',) # the paths whose reader expands a leading ~ itself
46
47
  cli_env = {} # set once, before the server starts; empty when it was started by hand
47
48
 
48
49
 
@@ -50,14 +51,15 @@ def cli_env_from(environ):
50
51
  """The CLI_ENV_NAMES set in `environ` (an empty value counts as unset, as it does for the CLIs).
51
52
 
52
53
  A relative FORGE_REGISTRY or FORGE_DATA_HOME is made absolute here: the server and its CLIs run in
53
- another directory than the shell that named it.
54
+ another directory than the shell that named it. A leading ~ is kept only where the reader expands it
55
+ (FORGE_DATA_HOME); a FORGE_REGISTRY of '~/x.tsv' is the relative path ./~/x.tsv to every CLI.
54
56
  """
55
57
  out = {}
56
58
  for name in CLI_ENV_NAMES:
57
59
  value = environ.get(name, '')
58
60
  if not value:
59
61
  continue
60
- if name in _CLI_ENV_PATHS and not value.startswith('~'):
62
+ if name in _CLI_ENV_PATHS and not (name in _CLI_ENV_EXPANDS_HOME and value.startswith('~')):
61
63
  value = os.path.abspath(value)
62
64
  out[name] = value
63
65
  return out
@@ -358,9 +360,16 @@ def _issues_file(ctx):
358
360
  if not isinstance(labels, list) or len(labels) > MAX_LABELS:
359
361
  raise BadBody(f'labels must be a list of at most {MAX_LABELS} strings')
360
362
  for label in labels:
361
- if (not isinstance(label, str) or not label.strip() or len(label) > MAX_LABEL
362
- or label.startswith('-') or not _no_control_chars(label)):
363
- raise BadBody(f'a label must be a text of 1 to {MAX_LABEL} characters, without control characters')
363
+ if not isinstance(label, str):
364
+ raise BadBody('a label must be a text')
365
+ if not label.strip():
366
+ raise BadBody('a label cannot be blank')
367
+ if len(label) > MAX_LABEL:
368
+ raise BadBody(f'a label is at most {MAX_LABEL} characters')
369
+ if label.startswith('-'):
370
+ raise BadBody('a label cannot start with "-"')
371
+ if not _no_control_chars(label):
372
+ raise BadBody('a label cannot hold control characters')
364
373
  except BadBody as exc:
365
374
  return _invalid(str(exc))
366
375
  path = ctx.locate()
@@ -538,6 +547,17 @@ def _config_set(ctx):
538
547
 
539
548
  # (method, path pattern, handler(ctx) -> (status, body)); group 1 of the pattern is the run key
540
549
  # (the config routes name it in the query or body instead). New routes are added here.
550
+ def _reviewer_outcomes(ctx):
551
+ # How the work each reviewer PASSed held up afterwards (`forge dataset reviewers`): advisory,
552
+ # read from the run dataset through the CLI, so forge_ui never opens the store itself.
553
+ ctx.route = 'reviewers'
554
+ try:
555
+ _query_params(ctx.query, ())
556
+ except BadBody as exc:
557
+ return _invalid(str(exc))
558
+ return ctx.call('dataset', 'reviewers', '--json')
559
+
560
+
541
561
  ROUTES = [
542
562
  ('GET', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _show),
543
563
  ('POST', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _change),
@@ -547,6 +567,7 @@ ROUTES = [
547
567
  ('POST', re.compile(r'/api/runs/([0-9a-f]{12})/delete'), _delete),
548
568
  ('GET', re.compile(r'/api/config()'), _config_get),
549
569
  ('POST', re.compile(r'/api/config()'), _config_set),
570
+ ('GET', re.compile(r'/api/models/reviewers()'), _reviewer_outcomes),
550
571
  ]
551
572
 
552
573