@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
package/ui/docs/api.md CHANGED
@@ -13,14 +13,16 @@ forge_ui/diffs.py unified diffs: parsing, the ```diff fence of a review pr
13
13
  forge_ui/models.py per-run model facts (attempts and reviews) for the leaderboard, cached per run
14
14
  forge_ui/fractal.py Fractal child tasks under $XDG_STATE_HOME/forge/fractal, read through dir fds
15
15
  forge_ui/transcripts.py an agent CLI's `<role>.log` read as one conversation, whichever harness wrote it
16
- forge_ui/server.py serves those dicts as JSON plus the static page, 127.0.0.1 only
16
+ forge_ui/server.py serves those dicts as JSON plus the static page, 127.0.0.1 only; authenticates writes
17
+ forge_ui/control.py the write routes: each one is a forge CLI call (see Controls)
17
18
  forge_ui/static/ the page: index.html, app.js, style.css, graph.js and the scripts they load
18
19
  ```
19
20
 
20
21
  Python 3.9+ standard library only. No third-party packages, no build step. The server makes
21
22
  no network requests; the page loads three.js 0.160.0 from cdn.jsdelivr.net only when the 3D
22
- view is used, and falls back to the 2D graph when it can't. The UI is read-only: nothing in
23
- forge-ui writes to a run dir, a repo or forge's config.
23
+ view is used, and falls back to the 2D graph when it can't. forge-ui never writes to a run dir,
24
+ a repo or forge's config itself: every change is one call of the forge CLI (see Controls (write
25
+ endpoints) below), and `--read-only` removes even that.
24
26
 
25
27
  ## What a forge plan dir looks like
26
28
 
@@ -48,17 +50,17 @@ written), and the reader must never raise because of one.
48
50
  | `deferrals.txt` | one line per task the wave planner deferred |
49
51
  | `integration.base`, `accepted.integration` | one commit each: where the integration branch started, the revision the run accepted |
50
52
  | `attempts/<role>-<uuid>/` | one *execution* (below) of the run itself: `parallel` (a `run`) or `verification` |
51
- | `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>/` |
52
54
  | `final-review` | a symlink to the newest *published* review dir; `final-review.verdict` a copy of its verdict |
53
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 |
54
56
  | `tasks/<id>/merged` | exists when the task was merged; its mtime is when |
55
57
  | `tasks/<id>/status` | one word: `RUNNING PASS FAIL ERROR INFRA TIMEOUT UNKNOWN INVALIDATED BLOCKED INTERRUPTED NOCHANGES CONFLICT` |
56
58
  | `tasks/<id>/attempt` | the current attempt number |
57
59
  | `tasks/<id>/attempts.tsv` | `epoch<TAB>attempt<TAB>status<TAB>infra_class` per line, one per attempt entry; status may also be `MERGED`, `infra_class` may be empty, `-` or missing |
58
- | `tasks/<id>/escalations.tsv` | `epoch<TAB>attempt<TAB>from_spec<TAB>to_spec` per line |
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` |
59
61
  | `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
60
62
  | `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
61
- | `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) |
62
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) |
63
65
  | `tasks/<id>/known_issues.md` | the confirmed findings a threshold tolerated |
64
66
  | `tasks/<id>/dwarf.input`, `qa.input` | the prompts forge assembled; `<role>.delivered`, `<role>.resolved` the live dispatch files |
@@ -178,8 +180,11 @@ class Catalog:
178
180
  fractal(self, key, task_id),
179
181
  fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
180
182
  def models(self) -> dict # Models, over every run
183
+ def locate(self, key) -> tuple[str, str] | None # (run dir, 'plan' | 'solo'); None for an unknown key
181
184
  ```
182
185
 
186
+ `Catalog.locate` is how a write finds its run: a client names a run by key and never by path.
187
+
183
188
  `Catalog` re-discovers and re-reads on every call: runs change while they are watched. Files
184
189
  that stop changing once written (`metrics.json`, `dataset.json`, `*.resolved` and `qa.last`
185
190
  copies, findings of a reply) are parsed once and kept by `(dev, inode, size, mtime_ns)`; a
@@ -221,12 +226,22 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
221
226
  | `GET /api/runs/<key>/findings` | `200 RunFindings` |
222
227
  | `GET /api/runs/<key>/executions/<qa-or-dwarf>/transcript` | `200 ExecutionTranscript` |
223
228
  | `GET /api/models` | `200 Models` |
229
+ | `GET /api/health` | `200 {"app": "forge-ui", "version", "build", "pid", "controls": true\|false}`; `controls` is `false` in read-only mode |
230
+ | `GET /api/runs/<key>/controls` | `200` the `forge control show --json` object of a plan run (see Controls); `404` for a solo or unknown run |
231
+ | `POST /api/runs/<key>/controls` | change one control or pin; authenticated, see Controls (write endpoints) |
232
+ | `GET /api/runs/<key>/issues?scope=run\|repo&offline=0\|1&plausible=0\|1&dismissed=0\|1&only=fp,fp` | `200` the `forge issues --json` listing, run fresh on every request (no cache); see Controls (write endpoints) |
233
+ | `POST /api/runs/<key>/issues` | file GitHub issues for chosen findings; authenticated, 300 s timeout |
234
+ | `POST /api/runs/<key>/issues/dismiss` | dismiss or (`undo`) restore findings; authenticated |
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 |
237
+ | `GET /api/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
238
+ | `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
224
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 |
225
240
  | `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
226
241
  | `GET /api/runs/<key>/tasks/<id>/fractal/nodes/<node>[/diff]` | `200 FractalNodeDetail`, or with `/diff` a `DiffDetail` of its `candidate.diff`; `<node>` matches `[A-Za-z0-9][A-Za-z0-9_-]{0,100}` |
227
242
  | `GET /` | `forge_ui/static/index.html` |
228
243
  | `GET /<name>` | that file from `forge_ui/static/` (`.html .js .css .svg` only, no subdirs) |
229
- | anything else | `404 {"error": "not found"}`; any method but GET or HEAD: `405` |
244
+ | anything else | `404 {"error": "not found"}` (an unknown path or run); a method a path does not take (PUT, DELETE, OPTIONS, a POST to a read path): `405` with `Allow`, except that a GET to a path that only takes POST (`/api/runs/<key>/delete`, `/api/runs/<key>/issues/dismiss`) answers `404` |
230
245
 
231
246
  The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
232
247
  execution or review; the diff endpoints also for a `<which>` the run kind has not (`existing`
@@ -272,18 +287,20 @@ nothing when there is none. Every other response keeps
272
287
  "counts": {"MERGED": 3, "RUNNING": 1, "PENDING": 1},
273
288
  "verification_status": "PASS",
274
289
  "created": 1760619000.0,
275
- "task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs"},
276
- {"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}],
277
292
  "models": ["opus", "sol:xhigh"],
278
293
  "dwarf_models": ["sol:xhigh"],
279
294
  "qa_models": ["opus"],
280
- "final_review": "FAIL"
295
+ "final_review": "FAIL",
296
+ "final_review_outdated": false
281
297
  }
282
298
  ```
283
299
 
284
- - `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty) and
285
- `title` (its first 160 characters, then `…`). A solo run: `[{"id": "solo", "status": <solo status>,
286
- "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`.
287
304
  - `created`: when the run began: the earliest birth time (else mtime) of the files a run writes
288
305
  once at its start (plan: `run_id`, `repo`, `request.md`, `goal.txt`, `tasks.tsv`; solo: `prompt.md`,
289
306
  `repo`, `request.md`, `goal.txt`), else `null`. These are stat'ed only, and never move `updated`.
@@ -292,6 +309,11 @@ nothing when there is none. Every other response keeps
292
309
  and `qa` specs as its task has them), nulls left out.
293
310
  - `final_review`: the first word of `final-review.verdict`, else `null`; always `null` for a
294
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`).
295
317
 
296
318
  `kind` is `"plan"` or `"solo"`. `updated` is the newest mtime (epoch seconds, float) among
297
319
  the run dir and the files the reader read. Any field it cannot determine is `null`
@@ -302,7 +324,11 @@ are `null`, `task_count` is 1, and `counts` is `{<status>: 1}` using the solo st
302
324
  The summary reads only `repo`, `goal.txt`, `status`, `verdict`, `dwarf.spec`, `qa.spec` and,
303
325
  when a spec file is missing, `*.resolved`. It also stats `dwarf.log` and `qa.log` without
304
326
  reading them, so `updated` moves while a model is writing a log. A plan summary reads
305
- `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.
306
332
 
307
333
  ## RunDetail
308
334
 
@@ -356,7 +382,9 @@ Every RunSummary field, plus:
356
382
  path of its review dir or `null`, `ended` the latest end of its verification and review
357
383
  executions (`null` while one is unfinished or there is none).
358
384
  - `final_review` is the newest FinalReviewSummary (review and integrate dirs together, by the
359
- 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.
360
388
  - `deferrals`: the non-empty lines of `deferrals.txt`, `[]` when it is missing.
361
389
  - `findings_available`: whether `scripts/forge-contract.py` could be loaded.
362
390
  - `fractal`: `null` without `fractal-selection.json`, else `{"enabled", "run", "available", "reason"}`
@@ -386,9 +414,12 @@ Every RunSummary field, plus:
386
414
  "attempt": 2,
387
415
  "attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
388
416
  {"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
389
- "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
417
+ "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
390
418
  "verdict": "PASS",
391
419
  "accepted_by": null,
420
+ "accepted": false,
421
+ "accepted_attempt": null,
422
+ "accepted_over": null,
392
423
  "explored": true,
393
424
  "route": [{"at": 1760619000.0, "task": "reader", "tier": "high", "epsilon": 0.5,
394
425
  "candidates": ["astra", "opus"], "default": "opus", "chosen": "astra",
@@ -421,7 +452,16 @@ Every RunSummary field, plus:
421
452
  - `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
422
453
  else `null`.
423
454
  - `infra` in an attempt: `null` for empty, `-` or missing.
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.
424
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.
425
465
  - `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
426
466
  plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
427
467
  Capped like `qa_tail`.
@@ -453,8 +493,9 @@ it is missing or `<harness default>`). `wave` is 1, `branch` is `null`, and `att
453
493
  from the run dir's `attempts.tsv`. `status` is the solo status; `attempt` is the highest `n`
454
494
  in `attempts.tsv`, or `null`. `escalations`, `route` and `shadow` are empty. `verdict` and
455
495
  `qa_tail` come from `qa.last`, and `prompt` comes from `prompt.md`. `deps` and `files` are
456
- `[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `explored` is
457
- 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`.
458
499
 
459
500
  ## Execution
460
501
 
@@ -523,6 +564,7 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
523
564
  ```json
524
565
  {"name": "final-review-1760622000-4242", "source": "review", "at": 1760622000.0, "published": true,
525
566
  "verdict": "FAIL", "state": "done", "spec": "opus", "threshold": "P2", "base": "1a2b...", "tip": "3c4d...",
567
+ "integration": "3c4d...", "outdated": false, "merged_since": [],
526
568
  "gate": {...}, "findings": FindingsSummary, "execution": "qa-...",
527
569
  "started": 1760622010.0, "ended": 1760622400.0, "duration_s": 390.0}
528
570
  ```
@@ -533,7 +575,29 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
533
575
  publishes nothing).
534
576
  - `state`: `done` with a verdict, `infra` with `qa.infra`, `running` when it is the newest
535
577
  review and has `qa.input`, else `orphaned`.
536
- - `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.
537
601
  - `execution` is its newest `attempts/qa-*`; `started`, `ended`, `duration_s` are that
538
602
  execution's.
539
603
 
@@ -647,13 +711,15 @@ lists, for all tasks and final reviews at once, without `text` or `line`.
647
711
  "verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
648
712
  "location": "ui/reader.py:42", "summary": "…"}]}]}],
649
713
  "final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
714
+ "outdated": true, "merged_since": ["reader"],
650
715
  "findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
651
716
  "truncated": [], "warnings": []}
652
717
  ```
653
718
 
654
719
  A task's `reviews` are every primary and shadow review of every attempt that listed a Finding
655
- (a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did. A
656
- 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
657
723
  `final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
658
724
  `id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
659
725
  contract module, and the lists are then empty.
@@ -892,10 +958,182 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
892
958
  style-src <sha256 of the style>; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none';
893
959
  form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
894
960
  JSON escapes `<`, `>` and `&`.
961
+ - The write token (below) is never in an export: the file has no controls.
895
962
  - In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
896
963
  exported run (any other route goes to it) and a banner with the export's date and what it left
897
964
  out; the cloud, the leaderboard and notifications are not there.
898
965
 
966
+ ## Controls (write endpoints)
967
+
968
+ forge-ui changes a run only by calling the forge CLI. The server holds no code that writes a run
969
+ dir, a repo or config; a write route builds an argv, runs it, and answers with what the CLI said.
970
+ `forge_ui/control.py` holds the route table (`ROUTES`: method, path pattern, handler), the body
971
+ validation, the call and the exit-code mapping; `server.py` does the authentication and gives
972
+ each authenticated POST to the table, so a new write route is one entry in it.
973
+
974
+ **The call.** `[<forge root>/forge, ...]`, an argv list with no shell, `stdin` closed, in a process
975
+ group of its own. `<forge root>` is two directories above `forge_ui/` and can be replaced as
976
+ `forge_ui.server.forge_cli`. A call that outlives its timeout (60 s) gets SIGTERM for its whole
977
+ group, SIGKILL 5 s later if anything is left, and is reaped, all before the run's lock is
978
+ released; the answer is `504 {"error": "timeout", "detail": "the operation may have partly
979
+ completed; reload to see the current state"}`.
980
+
981
+ **Runs are named by key.** The path holds the 12-hex run key and nothing else; the server finds
982
+ the run dir with `Catalog.locate(key)`. A body field never names a path, and a field the route does
983
+ not know is a `400`.
984
+
985
+ **Authentication.** Each server process makes a random token (`secrets.token_urlsafe(32)`). `GET /`
986
+ (and `/index.html`) carries it as `<meta name="forge-ui-token" content="...">` as the first element
987
+ of `<head>`; the page script reads it and sends it back. The page's CSP is unchanged (its hash
988
+ covers only the import map). Every POST must pass all of these, checked in this order, or it is
989
+ refused before any route or CLI runs:
990
+
991
+ | Check | Refusal |
992
+ |---|---|
993
+ | `Host` is `127.0.0.1` or `localhost` (any port) | `403 {"error": "forbidden"}` |
994
+ | server is not read-only | `405` (below) |
995
+ | `X-Forge-UI-Token` equals the token (`hmac.compare_digest`) | `403` |
996
+ | `Content-Type` is `application/json` (parameters allowed) | `400` |
997
+ | `Origin`, when sent, is exactly `http://` + the `Host` header | `403` |
998
+ | `Content-Length` present, a number, no `Transfer-Encoding` | `400` |
999
+ | the body is at most 64 KiB | `413 {"error": "payload too large"}` |
1000
+ | the body is UTF-8 JSON and an object | `400 {"error": "bad request"}` |
1001
+
1002
+ No response carries `Access-Control-*` headers and `OPTIONS` is a `405` (`Allow` lists the methods
1003
+ the path takes), so a page on another origin can neither preflight nor read an answer. The token is
1004
+ not in `/export`, in a log line or in any JSON response.
1005
+
1006
+ **Read-only mode.** `python3 -m forge_ui --read-only`, or `FORGE_UI_READONLY=1` (also `true`, `yes`,
1007
+ `on`). Every POST (after the Host check) is `405 {"error": "method not allowed"}` with
1008
+ `Allow: GET, HEAD`, the page has no token, and `/api/health` says `"controls": false`
1009
+ (`true` otherwise). Reads, `GET .../controls` included, still work. `forge ui open` passes the mode
1010
+ to the server it starts, records it in `server.json` (`read_only`) and never reuses a running server
1011
+ of the other mode: it replaces it as it does one running other code. `forge ui status` shows the
1012
+ mode (`read_only` in its JSON).
1013
+
1014
+ **The CLIs' configuration.** The server `forge ui open` starts runs without any `FORGE_*` variable
1015
+ of the shell it was started from. The CLIs its writes call still need the forge configuration, so
1016
+ `open` hands them these, as that shell has them (an empty value counts as unset; a relative
1017
+ `FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory):
1018
+ `FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
1019
+ `XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
1020
+ state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
1021
+ call gets them on top of the server's own environment; the server's environment itself is not
1022
+ changed. No other variable is passed, and none of these is a secret. `server.json` records them as
1023
+ `cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
1024
+
1025
+ `open` never reuses a server whose recorded `cli_env` differs from the current shell's. It names
1026
+ each variable that differs and both values, says to run `forge ui stop` and then open again, exits
1027
+ `5`, prints no link and leaves the server running (it may be in the middle of another shell's
1028
+ write). A server
1029
+ running other code, in the other mode or reading another registry directory is still replaced, and
1030
+ the new one gets the current shell's configuration.
1031
+
1032
+ **Serialization.** One write per run key at a time. A second write to a run whose write is still
1033
+ running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
1034
+
1035
+ **Errors.** For a CLI that exits non-zero the answer is `{"error", "detail", "result"?}`: `detail`
1036
+ is the last line of its stderr (at most 300 characters; the rest of stderr is not echoed) and
1037
+ `result` the object its stdout holds, when that parses as one.
1038
+
1039
+ | CLI exit | Status | `error` |
1040
+ |---|---|---|
1041
+ | 0 | `200` | the CLI's stdout JSON object, as it is |
1042
+ | 2 (invalid input) | `400` | `invalid` |
1043
+ | 3 (refused: not live, MERGED, a Fractal plan, a pin that fails doctor, the control lock busy) | `409` | `refused` |
1044
+ | 4 (a failure the CLI reported) | `500` | `failed` |
1045
+ | anything else, a signal, a missing CLI, or a 0 with no JSON object | `502` | `bad gateway` |
1046
+ | timeout | `504` | `timeout` |
1047
+
1048
+ For the controls routes, unknown run keys, solo runs (they have no controls) and runs the
1049
+ catalog does not hold are `404 {"error": "not found"}`. The issues, dismiss, delete and config
1050
+ routes below accept plan and solo runs alike (`forge issues` and `forge runs rm` take both);
1051
+ for them only unknown keys and runs the catalog does not hold are `404`. Every write that reaches a route logs
1052
+ `forge-ui: control <route> <run key> -> <status>` unless the server runs with `--quiet`.
1053
+
1054
+ ### `GET /api/runs/<key>/controls`
1055
+
1056
+ `forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
1057
+ `run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
1058
+ `retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
1059
+ is needed to read it.
1060
+
1061
+ ### `POST /api/runs/<key>/controls`
1062
+
1063
+ One of these bodies. Every string is non-empty, at most 256 characters, has no control character
1064
+ and does not start with `-`; `400 {"error": "invalid", "detail": ...}` otherwise, and for a wrong
1065
+ shape or type, before the CLI is called. Whether a key exists, a value is in range, a task is
1066
+ known or a spec runs is for the CLI to say (its `400` or `409` comes back as above).
1067
+
1068
+ | Body | CLI call (after `forge`) |
1069
+ |---|---|
1070
+ | `{"action": "set", "key": "qa-threshold", "value": "P2"}` | `control set <dir> qa-threshold P2 --actor ui --json` |
1071
+ | `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
1072
+ | `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
1073
+ | `{"action": "unpin", "task": "graph"}` | `control unpin <dir> graph --actor ui --json` |
1074
+
1075
+ `value` is always a string. `task` is optional on `set`, and only for the keys the CLI allows it
1076
+ for. A success is `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where
1077
+ `controls` is the `GET` object after the change.
1078
+
1079
+ ### `GET /api/runs/<key>/issues`
1080
+
1081
+ `forge issues <dir> --json [flags]`: the findings the run left open. Query (all optional, an
1082
+ unknown or repeated parameter is a `400`): `scope=run|repo` (`repo` adds `--all-runs`),
1083
+ `offline=0|1` (`--offline`), `plausible=0|1` (`--include-plausible`), `dismissed=0|1`
1084
+ (`--include-dismissed`), `only=fp,fp` (`--only`; one to 25 fingerprints, each `^[0-9a-f]{12}$`).
1085
+ `200` with the CLI's object (`findings`, ...). **Never cached**: every request is one CLI call; the
1086
+ page asks for an `offline=1` listing first for speed and then for the full one. It takes no lock (a
1087
+ busy CLI is its own exit 3, `409`). No token is needed. Plan and solo runs. Timeout 120 s.
1088
+
1089
+ ### `POST /api/runs/<key>/issues`
1090
+
1091
+ File issues for findings. Body: `{"scope": "run"|"repo" (default run), "fingerprints": [1 to 25 of
1092
+ ^[0-9a-f]{12}$], "include_plausible": bool, "labels": [at most 5 texts of 1 to 50 characters, no
1093
+ control character, not starting with "-"]}`; any other field is a `400`. Runs `issues <dir> --only
1094
+ <fps> --max-issues <number of fps> --apply --json` plus `--all-runs`, `--include-plausible` and one
1095
+ `--label` per label. `200` with the CLI's object. Under the run's write lock (`409 busy`). Timeout
1096
+ 300 s.
1097
+
1098
+ ### `POST /api/runs/<key>/issues/dismiss`
1099
+
1100
+ Body: `{"fingerprints": [1 to 25], "reason"?: text of at most 500 characters without control characters (newline and tab are allowed), "undo"?: bool}`. Runs
1101
+ `issues <dir> --json --dismiss <fps> [--reason R]`, or `--undismiss <fps>` when `undo` is true
1102
+ (a reason with `undo` is a `400`). Under the run's write lock. Timeout 60 s.
1103
+
1104
+ ### `POST /api/runs/<key>/delete`
1105
+
1106
+ Body: `{"dry_run": bool (false), "archive": bool (true), "worktrees": bool (false), "branches": bool
1107
+ (false)}`. Runs `runs rm <dir> --json` with `--dry-run` or `--yes`, plus `--no-archive` when
1108
+ `archive` is false, `--worktrees`, `--branches`. `200` with the CLI's object. The CLI prints its
1109
+ JSON on failure too, so a non-zero exit answers with that object as `result` (its `steps` show what
1110
+ completed): exit 2 `400`, exit 3 (busy or the run is active) `409`, exit 4 (failed during
1111
+ deletion) `500`. Deletion holds the run's write lock and a server-wide lock on the run's real
1112
+ directory (`409 busy` when either is held). The run key is looked up again under the lock, and
1113
+ once the directory is gone every later request for that key is `404`. Timeout 900 s.
1114
+
1115
+ ### `GET /api/models/reviewers`
1116
+
1117
+ Runs `dataset reviewers --json` and answers with its object: `horizon_days`, `min_landed`,
1118
+ `survives_at`, `reviewers` (per reviewer alias: `landed`, `reverted`, `rewritten`,
1119
+ `did_not_hold`, `enough`, and `rate` and `ci` only when `enough`, else `null` with a `note`),
1120
+ `unattributed`, `skipped` and `other_horizons`. Any query parameter is a `400`. Not cached: every
1121
+ request runs the CLI, and the page asks once per change of `/api/models`. Advisory: it feeds the
1122
+ Reviewers table's Did not hold column and nothing else. Timeout 60 s.
1123
+
1124
+ ### `GET /api/config?run=<key>` and `POST /api/config`
1125
+
1126
+ The repository is the run's own `repo` file (first line, an existing absolute directory; else
1127
+ `409`), never a client field. `GET` runs `config show --repo <repo> --json` and answers with its
1128
+ object. `POST` takes `{"run": <key>, "key": one of repo.verify, repo.setup, repo.verify-subset,
1129
+ jev.enabled, "value": text | "unset": true}` (exactly one of the two; any other field, `repo`
1130
+ included, is a `400`) and runs `config set <key> <value> --repo <repo> --json` or `config unset
1131
+ <key> --repo <repo> --json`. A value is a non-empty text of at most 4096 characters without
1132
+ control characters (newline and tab are allowed), not starting with `-`; `jev.enabled` takes
1133
+ `true` or `false` and cannot be unset; `repo.verify-subset` must contain `{files}`. `200` with the
1134
+ CLI's object (`ok`, `key`, `previous`, `value`). Writes serialize on one server-wide config lock
1135
+ (`409 busy`). Unknown run `404`. Timeout 60 s.
1136
+
899
1137
  ## Status vocabulary for the page and the graph
900
1138
 
901
1139
  Both the page and the graph mark a task by its status with the CSS class
@@ -21,7 +21,7 @@ DEFAULT_PORT = 8765
21
21
  def _parser():
22
22
  parser = argparse.ArgumentParser(
23
23
  prog='python3 -m forge_ui',
24
- description='A small, read-only local UI for forge runs. Listens on 127.0.0.1 only.')
24
+ description='A small local UI for forge runs. Listens on 127.0.0.1 only. Its changes to a run go through the forge CLI.')
25
25
  parser.add_argument('--plan', action='append', default=[], metavar='DIR',
26
26
  help='a plan dir to show (repeatable)')
27
27
  parser.add_argument('--root', action='append', default=[], metavar='DIR',
@@ -35,11 +35,19 @@ def _parser():
35
35
  parser.add_argument('--idle-exit', type=int, default=0, metavar='SECONDS',
36
36
  help='exit after SECONDS with no request (default 0, which never exits)')
37
37
  parser.add_argument('--quiet', action='store_true', help='do not log requests')
38
+ parser.add_argument('--read-only', action='store_true',
39
+ help='answer no write at all (also FORGE_UI_READONLY=1); the page shows no controls')
38
40
  parser.add_argument('--open', action='store_true', help='open the page in the browser once started')
39
41
  parser.add_argument('--version', action='version', version=f'forge-ui {__version__}')
40
42
  return parser
41
43
 
42
44
 
45
+ def read_only_requested(flag, environ=None):
46
+ """--read-only, or FORGE_UI_READONLY set to 1/true/yes/on."""
47
+ value = (os.environ if environ is None else environ).get('FORGE_UI_READONLY', '')
48
+ return bool(flag) or value.strip().lower() in ('1', 'true', 'yes', 'on')
49
+
50
+
43
51
  def _message(level, text):
44
52
  print(f'forge-ui: {level}: {text}', file=sys.stderr)
45
53
 
@@ -100,7 +108,7 @@ def main(argv=None):
100
108
  catalog = reader.Catalog(plans=args.plan, roots=args.root, registry=not args.no_registry,
101
109
  registry_dir=args.registry_dir)
102
110
  try:
103
- server = make_server(catalog, HOST, args.port, quiet=args.quiet)
111
+ server = make_server(catalog, HOST, args.port, quiet=args.quiet, read_only=read_only_requested(args.read_only))
104
112
  except OSError as exc:
105
113
  if exc.errno == errno.EADDRINUSE:
106
114
  _message('error', in_use)