@giamme/forge 0.8.0 → 0.10.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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
package/ui/docs/api.md CHANGED
@@ -12,14 +12,17 @@ forge_ui/findings.py QA findings and gate lines, parsed by scripts/forge-cont
12
12
  forge_ui/diffs.py unified diffs: parsing, the ```diff fence of a review prompt, pinning findings, cached reads
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
- forge_ui/server.py serves those dicts as JSON plus the static page, 127.0.0.1 only
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; authenticates writes
17
+ forge_ui/control.py the write routes: each one is a forge CLI call (see Controls)
16
18
  forge_ui/static/ the page: index.html, app.js, style.css, graph.js and the scripts they load
17
19
  ```
18
20
 
19
21
  Python 3.9+ standard library only. No third-party packages, no build step. The server makes
20
22
  no network requests; the page loads three.js 0.160.0 from cdn.jsdelivr.net only when the 3D
21
- view is used, and falls back to the 2D graph when it can't. The UI is read-only: nothing in
22
- 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.
23
26
 
24
27
  ## What a forge plan dir looks like
25
28
 
@@ -54,7 +57,7 @@ written), and the reader must never raise because of one.
54
57
  | `tasks/<id>/status` | one word: `RUNNING PASS FAIL ERROR INFRA TIMEOUT UNKNOWN INVALIDATED BLOCKED INTERRUPTED NOCHANGES CONFLICT` |
55
58
  | `tasks/<id>/attempt` | the current attempt number |
56
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 |
57
- | `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` |
58
61
  | `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
59
62
  | `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
60
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) |
@@ -85,7 +88,8 @@ one of `task dwarf qa solo verification parallel` and `<uuid>` 32 lowercase hex
85
88
  | `dataset.json` | may be absent: `parent` (the execution that started this one, e.g. `task-<uuid>`), `trigger`, `attempt_start` and `resume` (task executions) |
86
89
  | `<role>.resolved`, `.prompt`, `.last`, `.timedout` | copies made when it finished: the model resolution (`key=value`), the prompt, the final message, the timeout marker |
87
90
 
88
- The reader never opens an execution's `.log` or `.cmd`.
91
+ Only the transcript endpoint opens an execution's `.log` (a `dwarf`'s or `qa`'s, see
92
+ ExecutionTranscript); no read opens a `.cmd`.
89
93
 
90
94
  **Task status** is `MERGED` if `tasks/<id>/merged` exists, otherwise the first word of
91
95
  `tasks/<id>/status`, otherwise `PENDING`.
@@ -158,6 +162,8 @@ def task_diff(path, task_id, which='current', source='plan') -> dict | None # a
158
162
  def execution_diff(path, name, source='plan') -> dict | None # a DiffDetail
159
163
  def final_review_diff(path, name, source='plan') -> dict | None # a DiffDetail
160
164
  def files_detail(path, source='plan') -> dict | None # Files
165
+ def run_findings(path, source='plan') -> dict | None # RunFindings
166
+ def execution_transcript(path, name, source='plan') -> dict | None # an ExecutionTranscript
161
167
  def fractal_detail(path, task_id, source='plan') -> dict | None # a TaskFractal
162
168
  def fractal_node(path, task_id, node, source='plan') -> dict | None # a FractalNodeDetail
163
169
  def fractal_node_diff(path, task_id, node, source='plan') -> dict | None # a DiffDetail
@@ -170,11 +176,15 @@ class Catalog:
170
176
  def execution(self, key, name) -> dict | None
171
177
  def final_review(self, key, name) -> dict | None
172
178
  def diff(self, key, task_id, which='current'), execution_diff(self, key, name),
173
- final_review_diff(self, key, name), files(self, key), fractal(self, key, task_id),
179
+ final_review_diff(self, key, name), files(self, key), findings(self, key), transcript(self, key, name),
180
+ fractal(self, key, task_id),
174
181
  fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
175
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
176
184
  ```
177
185
 
186
+ `Catalog.locate` is how a write finds its run: a client names a run by key and never by path.
187
+
178
188
  `Catalog` re-discovers and re-reads on every call: runs change while they are watched. Files
179
189
  that stop changing once written (`metrics.json`, `dataset.json`, `*.resolved` and `qa.last`
180
190
  copies, findings of a reply) are parsed once and kept by `(dev, inode, size, mtime_ns)`; a
@@ -213,17 +223,29 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
213
223
  | `GET /api/runs/<key>/executions/<qa-or-dwarf>/diff` | `200 DiffDetail` of the diff that review read (a dwarf: its attempt's primary review) |
214
224
  | `GET /api/runs/<key>/final-reviews/<name>/diff` | `200 DiffDetail` |
215
225
  | `GET /api/runs/<key>/files` | `200 Files` |
226
+ | `GET /api/runs/<key>/findings` | `200 RunFindings` |
227
+ | `GET /api/runs/<key>/executions/<qa-or-dwarf>/transcript` | `200 ExecutionTranscript` |
216
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/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
237
+ | `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
217
238
  | `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 |
218
239
  | `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
219
240
  | `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}` |
220
241
  | `GET /` | `forge_ui/static/index.html` |
221
242
  | `GET /<name>` | that file from `forge_ui/static/` (`.html .js .css .svg` only, no subdirs) |
222
- | anything else | `404 {"error": "not found"}`; any method but GET or HEAD: `405` |
243
+ | 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` |
223
244
 
224
245
  The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
225
246
  execution or review; the diff endpoints also for a `<which>` the run kind has not (`existing`
226
- of a plan task, `prev` of a solo run) and for an execution that is not `qa` or `dwarf`; the
247
+ of a plan task, `prev` of a solo run), the diff and transcript endpoints for an execution that is
248
+ not `qa` or `dwarf`; the
227
249
  Fractal node endpoints for an unknown node or a task whose Fractal state is not `available`. A
228
250
  diff that is not there yet is a `200` with `available: false`. An execution is looked for only where executions are written:
229
251
  `attempts/`, `tasks/<id>/attempts/`, `shadow/<id>/<a>-<k>/attempts/`,
@@ -378,7 +400,7 @@ Every RunSummary field, plus:
378
400
  "attempt": 2,
379
401
  "attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
380
402
  {"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
381
- "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
403
+ "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
382
404
  "verdict": "PASS",
383
405
  "accepted_by": null,
384
406
  "explored": true,
@@ -413,6 +435,7 @@ Every RunSummary field, plus:
413
435
  - `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
414
436
  else `null`.
415
437
  - `infra` in an attempt: `null` for empty, `-` or missing.
438
+ - `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.
416
439
  - `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
417
440
  - `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
418
441
  plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
@@ -577,7 +600,7 @@ of Findings, in place of the summary), `truncated` and `warnings`.
577
600
  "additions": 5, "deletions": 1, "truncated": false,
578
601
  "hunks": [{"old_start": 38, "old_lines": 7, "new_start": 38, "new_lines": 10,
579
602
  "section": "def _tasks(reader):", "lines": [" ctx", "-old", "+new"]}]}],
580
- "findings": [{"review": {"kind": "primary", "execution": "qa-…", "attempt": 2, "spec": "opus"},
603
+ "findings": [{"id": "qa-…:0", "review": {"kind": "primary", "execution": "qa-…", "attempt": 2, "spec": "opus"},
581
604
  "severity": "P1", "status": "CONFIRMED", "location": "ui/reader.py:42",
582
605
  "summary": "…", "text": "…",
583
606
  "pin": {"file": 0, "path": "ui/reader.py", "line": 42, "side": "new", "hunk": 0, "index": 5},
@@ -597,7 +620,7 @@ of Findings, in place of the summary), `truncated` and `warnings`.
597
620
  | `truncated` | the read stopped at 8 MiB (at a line end), the fence never closed, more than 300 files, or a file is `truncated` |
598
621
  | `files[]` | per file of the diff: `old` (`null` when added), `new` (`null` when deleted), `status` `modified added deleted renamed copied`, `binary` (`Binary files … differ` or a `GIT binary patch`, whose body is skipped), `additions`, `deletions` (of the whole file, even when cut), `truncated`, `hunks` |
599
622
  | `hunks[]` | `old_start`, `old_lines`, `new_start`, `new_lines` (a count left out is 1), `section` (after the second `@@`, or `null`), `lines`: each raw line with its marker (` `, `-`, `+`, `\` for ``) |
600
- | `findings[]` | the Findings (as above, without `line`) of the reviews attached, each with its `review` (`kind` `primary` `shadow` `final`, `execution`, `attempt`, `spec`), `pin` and `off_diff` |
623
+ | `findings[]` | the Findings (as above, without `line`) of the reviews attached, each with its `id` (see RunFindings), its `review` (`kind` `primary` `shadow` `final`, `execution`, `attempt`, `spec`), `pin` and `off_diff` |
601
624
  | `pin` | `file` (index in `files`), `path`, `line`, `side` (`new`; `old` for a deleted file or a renamed file's old path), `hunk` and `index` (into that hunk's `lines`), or `null` |
602
625
  | `off_diff` | `null` when pinned to a line, else `no location`, `file not in this diff`, `ambiguous path` or `line outside the changed hunks` (then `pin` has `file`, `line`, `side` and `hunk`/`index` `null`) |
603
626
 
@@ -628,6 +651,77 @@ Reads stay inside the run dir after symlinks are resolved, open regular files on
628
651
  never opened) and read at most 8 MiB; parses are kept by `(dev, inode, size, mtime_ns)` as
629
652
  other parses are.
630
653
 
654
+ ## RunFindings
655
+
656
+ Every review's Findings in one read, for the stage's marks: what a TaskDetail or DiffDetail
657
+ lists, for all tasks and final reviews at once, without `text` or `line`.
658
+
659
+ ```json
660
+ {"run": "3f9a0c1d2e4b", "available": true,
661
+ "tasks": [{"id": "reader", "reviews": [{"kind": "primary", "execution": "qa-…", "attempt": 1, "spec": "opus",
662
+ "verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
663
+ "location": "ui/reader.py:42", "summary": "…"}]}]}],
664
+ "final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
665
+ "findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
666
+ "truncated": [], "warnings": []}
667
+ ```
668
+
669
+ 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
672
+ `final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
673
+ `id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
674
+ contract module, and the lists are then empty.
675
+
676
+ ## ExecutionTranscript (`forge_ui/transcripts.py`)
677
+
678
+ What the agent of one `dwarf` or `qa` execution said and did, read from its `<role>.log`: the
679
+ copy in its execution dir once it has finished, the owner dir's log while it runs (up to its
680
+ last whole line, so the page can poll it). The format is told from the log itself:
681
+
682
+ | Log | Written by | Read as |
683
+ |---|---|---|
684
+ | one JSON event per line: `thread.started`, `item.*`, `turn.*` | codex `--json` | `format` `codex` |
685
+ | one message per line in Claude's shape: `system`, `assistant`, `user`, `result` | grok `--output-format streaming-messages-json`; claude and openclaude `--output-format stream-json` | `claude` |
686
+ | one event per line: `text`, `reasoning`, `tool_use`, `step_finish`, `error` | opencode `--format json` | `opencode` |
687
+ | one `result` object with a `session_id` | claude `--output-format json` | the session file, see below |
688
+ | other JSON lines | e.g. agy `--output-format stream-json` (its `{"event": "result"}` is read as the result) | `json`: each line shown as it is |
689
+ | anything else | antigravity's printed output; openclaude and opencode runs from before forge streamed them | `text`: one event |
690
+
691
+ A claude log that holds only its result names the session the conversation is in. That one
692
+ file is read, `<config dir>/projects/<one dir>/<session id>.jsonl`, the config dir being
693
+ `$CLAUDE_CONFIG_DIR` then `~/.claude`, and only when the id is a UUID; it is the one file
694
+ outside the run dir forge-ui reads. Its subagent (`isSidechain`) and meta lines are left out.
695
+ Without it, the result alone is shown with a warning.
696
+
697
+ ```json
698
+ {"run": "3f9a0c1d2e4b", "execution": "dwarf-…", "role": "dwarf", "state": "done", "attempt": 1, "spec": "luna",
699
+ "harness": "codex", "repo": "/work/worktrees/reader", "owner": {"kind": "task", "id": "reader"},
700
+ "format": "codex", "source": "log", "session": "0199…", "model": null, "cost": null,
701
+ "tokens": {"input": 12000, "cached": 9000, "output": 800},
702
+ "events": [{"kind": "reasoning", "text": "…"},
703
+ {"kind": "command", "command": "rg -n status ui/reader.py", "output": "…", "exit": 0},
704
+ {"kind": "edit", "files": ["/repo/ui/reader.py"], "patch": "-old\n+new"},
705
+ {"kind": "message", "text": "…"}],
706
+ "truncated": false, "warnings": []}
707
+ ```
708
+
709
+ `attempt`, `spec` and `owner` are the Execution's; `harness` and `repo` (the worktree the agent ran
710
+ in, so the page can make its absolute paths relative) come from `<role>.resolved`. `source` is
711
+ `log`, `session` (claude's session file) or `none` (no log). An event has `kind`
712
+ and the keys that kind uses: `prompt` (`text`: the task as a session file holds it), `message`,
713
+ `reasoning`, `command` (`command`, `output`, `exit`), `edit` (`files`, `patch`: `-`/`+` lines,
714
+ or `input` when there are none), `tool` (`tool`, `input`, `output`), `result` (`text`, left out
715
+ with `same: true` when the last message said it; `tokens`), `error` (`text`), `text` (`text`,
716
+ `stream: "stderr"` for lines between JSON events) and `gap` (`count` events left out). Any event
717
+ may have `t` (epoch seconds), `id` (the harness's call id), `error` (`true` for a failed call or a
718
+ non-zero exit, `false` for a call answered without one) and `running: true` (a call not finished yet). `tokens` are the run's totals
719
+ (`input` counts cached input too). A message, a reasoning or a result keeps 20,000 characters, a
720
+ command, an input or an output its first two thirds and last third of 8,000 around `… n
721
+ characters …` (`<key>_cut` holds n). A log over 32 MB is read from its end; past 5,000 events or
722
+ 4,000,000 characters the middle is left out (a `gap` event, `truncated: true`). Parses are kept by
723
+ the log's `(path, size, mtime_ns)` and the session file's.
724
+
631
725
  ## Files
632
726
 
633
727
  `GET /api/runs/<key>/files`: who planned, changed, drifted into or conflicted on each file.
@@ -785,21 +879,23 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
785
879
  `GET /api/runs/<key>/export` answers one self-contained HTML file named
786
880
  `forge-run-<run_id>.html` (the run id with anything but `[A-Za-z0-9._-]` made `-`).
787
881
 
788
- - It holds `style.css` and the classic scripts the run view uses (`runmodel.js cloudmodel.js
789
- graph.js gantt.js panel.js diffview.js files.js map3d.js search.js app.js`), inline; the import
790
- map; a `<script type="application/json" id="forge-snapshot">` with `{meta, api}`; and a
791
- `<script type="text/plain" id="forge-stage">` holding `iso.js`, `cloud3d.js` and `scene3d.js`
792
- bundled into one module (each module in its own scope, relative imports bound to the earlier
793
- ones' exports, `createStage` exported), which `map3d.js` imports from a `blob:` URL.
882
+ - It holds `style.css` (with the labels' face, `D-DINCondensed-Bold.woff2`, inlined as a `data:`
883
+ URL) and the classic scripts the run view uses (`theme.js icons.js datatable.js shell.js
884
+ runmodel.js cloudmodel.js graph.js gantt.js panel.js diffview.js convo.js files.js map3d.js search.js
885
+ app.js`), inline; the import map; a `<script type="application/json" id="forge-snapshot">` with
886
+ `{meta, api}`; and a `<script type="text/plain" id="forge-stage">` holding `iso.js`,
887
+ `cloud3d.js`, `findings3d.js`, `stagectl.js` and `scene3d.js` bundled into one module (each
888
+ module in its own scope, relative imports bound to the earlier ones' exports, `createStage`
889
+ exported), which `map3d.js` imports from a `blob:` URL.
794
890
  - `api` maps each path the page requests to that endpoint's response as the server would answer
795
891
  it (server.api_get): `/api/runs` (this run only), the RunDetail, every TaskDetail, every
796
- ExecutionDetail the RunDetail names, every FinalReviewDetail, Files, and the Fractal trees and
797
- nodes. With `texts=1` also every diff the page can open (each task's diff and its `sources`,
798
- each final review's, each Fractal node's).
892
+ ExecutionDetail the RunDetail names, every FinalReviewDetail, Files, RunFindings, and the
893
+ Fractal trees and nodes. With `texts=1` also every diff the page can open (each task's diff and its `sources`,
894
+ each final review's, each Fractal node's) and every `dwarf` and `qa` ExecutionTranscript (at most 2 MB each).
799
895
  - `texts=0` (the default) nulls these keys wherever they hold a string, list or object: `prompt
800
896
  last request log_tail dwarf_tail qa_tail qa_last qa_input tasks_summary known_issues texts text
801
- planning_last approach retry_findings dwarf_input error`, and stores no diff. A finding keeps its
802
- `summary`.
897
+ planning_last approach retry_findings dwarf_input error`, and stores no diff and no transcript. A
898
+ finding keeps its `summary`.
803
899
  - `paths=0` (the default) makes every string that is a whole absolute path (but `/api/…` and
804
900
  `url`s) its last component, and replaces the run dir, the repo and the home dir inside other
805
901
  strings with their last component (`~` for home).
@@ -808,13 +904,176 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
808
904
  `generated`, `texts`, `paths`, `omitted`.
809
905
  - The page's `<meta http-equiv="Content-Security-Policy">`: `default-src 'none'; script-src
810
906
  <sha256 of the import map and each inline script> blob: https://cdn.jsdelivr.net/npm/three@0.160.0/;
811
- style-src <sha256 of the style>; img-src data: blob:; connect-src 'none'; base-uri 'none';
907
+ style-src <sha256 of the style>; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none';
812
908
  form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
813
909
  JSON escapes `<`, `>` and `&`.
910
+ - The write token (below) is never in an export: the file has no controls.
814
911
  - In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
815
912
  exported run (any other route goes to it) and a banner with the export's date and what it left
816
913
  out; the cloud, the leaderboard and notifications are not there.
817
914
 
915
+ ## Controls (write endpoints)
916
+
917
+ forge-ui changes a run only by calling the forge CLI. The server holds no code that writes a run
918
+ dir, a repo or config; a write route builds an argv, runs it, and answers with what the CLI said.
919
+ `forge_ui/control.py` holds the route table (`ROUTES`: method, path pattern, handler), the body
920
+ validation, the call and the exit-code mapping; `server.py` does the authentication and gives
921
+ each authenticated POST to the table, so a new write route is one entry in it.
922
+
923
+ **The call.** `[<forge root>/forge, ...]`, an argv list with no shell, `stdin` closed, in a process
924
+ group of its own. `<forge root>` is two directories above `forge_ui/` and can be replaced as
925
+ `forge_ui.server.forge_cli`. A call that outlives its timeout (60 s) gets SIGTERM for its whole
926
+ group, SIGKILL 5 s later if anything is left, and is reaped, all before the run's lock is
927
+ released; the answer is `504 {"error": "timeout", "detail": "the operation may have partly
928
+ completed; reload to see the current state"}`.
929
+
930
+ **Runs are named by key.** The path holds the 12-hex run key and nothing else; the server finds
931
+ the run dir with `Catalog.locate(key)`. A body field never names a path, and a field the route does
932
+ not know is a `400`.
933
+
934
+ **Authentication.** Each server process makes a random token (`secrets.token_urlsafe(32)`). `GET /`
935
+ (and `/index.html`) carries it as `<meta name="forge-ui-token" content="...">` as the first element
936
+ of `<head>`; the page script reads it and sends it back. The page's CSP is unchanged (its hash
937
+ covers only the import map). Every POST must pass all of these, checked in this order, or it is
938
+ refused before any route or CLI runs:
939
+
940
+ | Check | Refusal |
941
+ |---|---|
942
+ | `Host` is `127.0.0.1` or `localhost` (any port) | `403 {"error": "forbidden"}` |
943
+ | server is not read-only | `405` (below) |
944
+ | `X-Forge-UI-Token` equals the token (`hmac.compare_digest`) | `403` |
945
+ | `Content-Type` is `application/json` (parameters allowed) | `400` |
946
+ | `Origin`, when sent, is exactly `http://` + the `Host` header | `403` |
947
+ | `Content-Length` present, a number, no `Transfer-Encoding` | `400` |
948
+ | the body is at most 64 KiB | `413 {"error": "payload too large"}` |
949
+ | the body is UTF-8 JSON and an object | `400 {"error": "bad request"}` |
950
+
951
+ No response carries `Access-Control-*` headers and `OPTIONS` is a `405` (`Allow` lists the methods
952
+ the path takes), so a page on another origin can neither preflight nor read an answer. The token is
953
+ not in `/export`, in a log line or in any JSON response.
954
+
955
+ **Read-only mode.** `python3 -m forge_ui --read-only`, or `FORGE_UI_READONLY=1` (also `true`, `yes`,
956
+ `on`). Every POST (after the Host check) is `405 {"error": "method not allowed"}` with
957
+ `Allow: GET, HEAD`, the page has no token, and `/api/health` says `"controls": false`
958
+ (`true` otherwise). Reads, `GET .../controls` included, still work. `forge ui open` passes the mode
959
+ to the server it starts, records it in `server.json` (`read_only`) and never reuses a running server
960
+ of the other mode: it replaces it as it does one running other code. `forge ui status` shows the
961
+ mode (`read_only` in its JSON).
962
+
963
+ **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
965
+ `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):
967
+ `FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
968
+ `XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
969
+ state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
970
+ call gets them on top of the server's own environment; the server's environment itself is not
971
+ changed. No other variable is passed, and none of these is a secret. `server.json` records them as
972
+ `cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
973
+
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.
980
+
981
+ **Serialization.** One write per run key at a time. A second write to a run whose write is still
982
+ running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
983
+
984
+ **Errors.** For a CLI that exits non-zero the answer is `{"error", "detail", "result"?}`: `detail`
985
+ is the last line of its stderr (at most 300 characters; the rest of stderr is not echoed) and
986
+ `result` the object its stdout holds, when that parses as one.
987
+
988
+ | CLI exit | Status | `error` |
989
+ |---|---|---|
990
+ | 0 | `200` | the CLI's stdout JSON object, as it is |
991
+ | 2 (invalid input) | `400` | `invalid` |
992
+ | 3 (refused: not live, MERGED, a Fractal plan, a pin that fails doctor, the control lock busy) | `409` | `refused` |
993
+ | 4 (a failure the CLI reported) | `500` | `failed` |
994
+ | anything else, a signal, a missing CLI, or a 0 with no JSON object | `502` | `bad gateway` |
995
+ | timeout | `504` | `timeout` |
996
+
997
+ For the controls routes, unknown run keys, solo runs (they have no controls) and runs the
998
+ catalog does not hold are `404 {"error": "not found"}`. The issues, dismiss, delete and config
999
+ routes below accept plan and solo runs alike (`forge issues` and `forge runs rm` take both);
1000
+ for them only unknown keys and runs the catalog does not hold are `404`. Every write that reaches a route logs
1001
+ `forge-ui: control <route> <run key> -> <status>` unless the server runs with `--quiet`.
1002
+
1003
+ ### `GET /api/runs/<key>/controls`
1004
+
1005
+ `forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
1006
+ `run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
1007
+ `retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
1008
+ is needed to read it.
1009
+
1010
+ ### `POST /api/runs/<key>/controls`
1011
+
1012
+ One of these bodies. Every string is non-empty, at most 256 characters, has no control character
1013
+ and does not start with `-`; `400 {"error": "invalid", "detail": ...}` otherwise, and for a wrong
1014
+ shape or type, before the CLI is called. Whether a key exists, a value is in range, a task is
1015
+ known or a spec runs is for the CLI to say (its `400` or `409` comes back as above).
1016
+
1017
+ | Body | CLI call (after `forge`) |
1018
+ |---|---|
1019
+ | `{"action": "set", "key": "qa-threshold", "value": "P2"}` | `control set <dir> qa-threshold P2 --actor ui --json` |
1020
+ | `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
1021
+ | `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
1022
+ | `{"action": "unpin", "task": "graph"}` | `control unpin <dir> graph --actor ui --json` |
1023
+
1024
+ `value` is always a string. `task` is optional on `set`, and only for the keys the CLI allows it
1025
+ for. A success is `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where
1026
+ `controls` is the `GET` object after the change.
1027
+
1028
+ ### `GET /api/runs/<key>/issues`
1029
+
1030
+ `forge issues <dir> --json [flags]`: the findings the run left open. Query (all optional, an
1031
+ unknown or repeated parameter is a `400`): `scope=run|repo` (`repo` adds `--all-runs`),
1032
+ `offline=0|1` (`--offline`), `plausible=0|1` (`--include-plausible`), `dismissed=0|1`
1033
+ (`--include-dismissed`), `only=fp,fp` (`--only`; one to 25 fingerprints, each `^[0-9a-f]{12}$`).
1034
+ `200` with the CLI's object (`findings`, ...). **Never cached**: every request is one CLI call; the
1035
+ page asks for an `offline=1` listing first for speed and then for the full one. It takes no lock (a
1036
+ busy CLI is its own exit 3, `409`). No token is needed. Plan and solo runs. Timeout 120 s.
1037
+
1038
+ ### `POST /api/runs/<key>/issues`
1039
+
1040
+ File issues for findings. Body: `{"scope": "run"|"repo" (default run), "fingerprints": [1 to 25 of
1041
+ ^[0-9a-f]{12}$], "include_plausible": bool, "labels": [at most 5 texts of 1 to 50 characters, no
1042
+ control character, not starting with "-"]}`; any other field is a `400`. Runs `issues <dir> --only
1043
+ <fps> --max-issues <number of fps> --apply --json` plus `--all-runs`, `--include-plausible` and one
1044
+ `--label` per label. `200` with the CLI's object. Under the run's write lock (`409 busy`). Timeout
1045
+ 300 s.
1046
+
1047
+ ### `POST /api/runs/<key>/issues/dismiss`
1048
+
1049
+ Body: `{"fingerprints": [1 to 25], "reason"?: text of at most 500 characters without control characters (newline and tab are allowed), "undo"?: bool}`. Runs
1050
+ `issues <dir> --json --dismiss <fps> [--reason R]`, or `--undismiss <fps>` when `undo` is true
1051
+ (a reason with `undo` is a `400`). Under the run's write lock. Timeout 60 s.
1052
+
1053
+ ### `POST /api/runs/<key>/delete`
1054
+
1055
+ Body: `{"dry_run": bool (false), "archive": bool (true), "worktrees": bool (false), "branches": bool
1056
+ (false)}`. Runs `runs rm <dir> --json` with `--dry-run` or `--yes`, plus `--no-archive` when
1057
+ `archive` is false, `--worktrees`, `--branches`. `200` with the CLI's object. The CLI prints its
1058
+ JSON on failure too, so a non-zero exit answers with that object as `result` (its `steps` show what
1059
+ completed): exit 2 `400`, exit 3 (busy or the run is active) `409`, exit 4 (failed during
1060
+ deletion) `500`. Deletion holds the run's write lock and a server-wide lock on the run's real
1061
+ directory (`409 busy` when either is held). The run key is looked up again under the lock, and
1062
+ once the directory is gone every later request for that key is `404`. Timeout 900 s.
1063
+
1064
+ ### `GET /api/config?run=<key>` and `POST /api/config`
1065
+
1066
+ The repository is the run's own `repo` file (first line, an existing absolute directory; else
1067
+ `409`), never a client field. `GET` runs `config show --repo <repo> --json` and answers with its
1068
+ object. `POST` takes `{"run": <key>, "key": one of repo.verify, repo.setup, repo.verify-subset,
1069
+ jev.enabled, "value": text | "unset": true}` (exactly one of the two; any other field, `repo`
1070
+ included, is a `400`) and runs `config set <key> <value> --repo <repo> --json` or `config unset
1071
+ <key> --repo <repo> --json`. A value is a non-empty text of at most 4096 characters without
1072
+ control characters (newline and tab are allowed), not starting with `-`; `jev.enabled` takes
1073
+ `true` or `false` and cannot be unset; `repo.verify-subset` must contain `{files}`. `200` with the
1074
+ CLI's object (`ok`, `key`, `previous`, `value`). Writes serialize on one server-wide config lock
1075
+ (`409 busy`). Unknown run `404`. Timeout 60 s.
1076
+
818
1077
  ## Status vocabulary for the page and the graph
819
1078
 
820
1079
  Both the page and the graph mark a task by its status with the CSS class
@@ -860,6 +1119,7 @@ Plain scripts unless noted; each defines one global and none parses API text as
860
1119
  | `gantt.js` | `ForgeGantt` | `render(container, run, {now, onSelect, sel, replay, critical, ghosts})`: attempts on wall-clock time in lanes; `strip(container, run, {now, replay})`: busy attempts and model calls against `max_parallel` |
861
1120
  | `panel.js` | `ForgePanel` | `render(host, ctx)`: the inspector for a run, task, execution, final review, verification, integration or Fractal node; fetches the lazy endpoints once per node; `ctx.diffHash(spec)` links into the diff viewer, `ctx.onFractal(task, TaskFractal)` hands a task's tree to the stage |
862
1121
  | `diffview.js` | `ForgeDiff` | `render(host, {run, spec, fetchJSON, rerender, href, closeHref})`: a DiffDetail with its findings under their lines; `spec` is `{kind: task, task, source, file}`, `{kind: final, name, file}` or `{kind: fractal, task, node, file}` |
1122
+ | `convo.js` | `ForgeConvo` | `render(host, {run, spec: {task, exec}, brief, live, fetchJSON, rerender, diffHash, closeHref})`: an ExecutionTranscript as a timeline, with search, the errors and, while the execution runs, a read of its log every 2.5 s |
863
1123
  | `files.js` | `ForgeFiles` | `matrix(data, run, opts)` (pure: Files as rows of dirs and files against task columns by wave) and `render(host, data, {run, sel, onSelectTask, diffHref, prefs, savePrefs})`: the run's Files mode. Also a CommonJS module |
864
1124
  | `cloudmodel.js` | `ForgeCloudModel` | pure: projects, per-run facts, the cloud's filters, its five layouts (anchors from every run, dragged runs kept), label picking, layout files. Also a CommonJS module |
865
1125
  | `cloudui.js` | `ForgeCloudUI` | `create(root, ctx)`: the cloud's filter panel, layout and shape switches, position tools, legend and run card; a list of the same runs without 3D |
@@ -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)