@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
@@ -5,6 +5,17 @@ run-dir path per line, with a ``#`` header on a new file. ``forge jev``, ``forge
5
5
  dataset import --registry`` and the forge-ui launcher all use it, so it does not
6
6
  live inside the Jev package.
7
7
 
8
+ Two writers change those files: ``register_run_dir`` (every runner, on every start) and
9
+ ``unregister_run_dir`` (``forge runs rm``). Both take an flock on ``<registry dir>/.lock``,
10
+ a stable sibling file that is never replaced. Register never blocks: it waits at most
11
+ ``REGISTER_LOCK_WAIT_S`` for the lock and then appends anyway, because a rare duplicate
12
+ line is harmless and a lost run is not. An unlocked append can then land in the file an
13
+ unregister has read and is about to replace, so the two meet halfway: unregister, once it
14
+ has replaced a file, copies onto the new one whatever was appended to the old one after
15
+ its read, and register, once it has appended, checks that the file it wrote is still the
16
+ registry and appends again to the new one if it is not. Either the append reached the old
17
+ file before unregister looked at it again, or register sees the replacement.
18
+
8
19
  Run as a script, ``forge_registry.py register <run-dir>`` records a run dir from the
9
20
  shell runners (forge-ui-options.sh ``forge_ui_register``): every plan, run and solo run
10
21
  is registered, not only the ones opened with ``--ui``, because forge-ui's map of a
@@ -12,12 +23,27 @@ repo's runs reads this registry. ``FORGE_RUN_REGISTRY=off`` turns that off.
12
23
  """
13
24
  from __future__ import annotations
14
25
 
26
+ import contextlib
27
+ import errno
28
+ import fcntl
15
29
  import hashlib
16
30
  import os
17
31
  import stat
18
32
  import sys
33
+ import tempfile
34
+ import time
19
35
  from pathlib import Path
20
36
 
37
+ # register_run_dir runs before forge ui open's own timeouts: it waits this long for the lock,
38
+ # then appends without it. unregister_run_dir is only ever called by `forge runs rm`, which
39
+ # can afford to wait for a busy lock, but not for ever.
40
+ REGISTER_LOCK_WAIT_S = 0.5
41
+ UNREGISTER_LOCK_WAIT_S = 10.0
42
+ # How many times an unlocked register appends again after an unregister replaced the file
43
+ # under it. One is enough unless unregisters keep finishing back to back.
44
+ REGISTER_TRIES = 5
45
+ LOCK_NAME = '.lock'
46
+
21
47
 
22
48
  def registry_dir() -> Path:
23
49
  """The ``runs`` directory under forge's config home.
@@ -51,11 +77,45 @@ def runs_registry(repo) -> Path:
51
77
  return registry_dir() / (repo_key(repo) + '.txt')
52
78
 
53
79
 
80
+ @contextlib.contextmanager
81
+ def _registry_lock(wait_s: float):
82
+ """Hold an exclusive flock on ``<registry dir>/.lock`` for the ``with`` body.
83
+
84
+ The lock is on a file of its own and never on a registry file, because unregister
85
+ replaces those: a lock on a file that is renamed over locks nothing. Polls without
86
+ blocking for up to ``wait_s`` seconds, then raises BlockingIOError; raises OSError if
87
+ the lock file cannot be opened or is not a regular file (a FIFO would block a reader).
88
+ A filesystem that cannot flock at all (ENOLCK, EOPNOTSUPP) gets no locking, as it
89
+ would anywhere else in forge, rather than a registry nobody can change.
90
+ """
91
+ fd = os.open(registry_dir() / LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
92
+ try:
93
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
94
+ raise OSError(errno.EINVAL, 'the registry lock is not a regular file', LOCK_NAME)
95
+ deadline = time.monotonic() + wait_s
96
+ while True:
97
+ try:
98
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
99
+ break
100
+ except OSError as error:
101
+ if error.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
102
+ break
103
+ if time.monotonic() >= deadline:
104
+ raise BlockingIOError(errno.EWOULDBLOCK, 'the run registry is locked', LOCK_NAME) from error
105
+ time.sleep(0.01)
106
+ yield
107
+ finally:
108
+ os.close(fd)
109
+
110
+
54
111
  def register_run_dir(repo: Path, run_dir: Path) -> None:
55
112
  """Record an absolute run-dir path for this repo, deduplicated.
56
113
 
57
114
  Never raises and never blocks: failing to note where a run lives must not fail the run,
58
- and ``forge ui open`` calls this before any of its own timeouts.
115
+ and ``forge ui open`` calls this before any of its own timeouts. The registry lock is
116
+ tried for at most ``REGISTER_LOCK_WAIT_S``; if it stays busy the line is appended without
117
+ it (see the module docstring), and appended again if an unregister replaced the file
118
+ it went to.
59
119
  """
60
120
  try:
61
121
  # A relative XDG_CONFIG_HOME resolves below the current directory -- for a runner or
@@ -65,26 +125,118 @@ def register_run_dir(repo: Path, run_dir: Path) -> None:
65
125
  registry = runs_registry(repo)
66
126
  registry.parent.mkdir(parents=True, exist_ok=True)
67
127
  line = str(Path(run_dir).resolve())
68
- existing = []
69
- if registry.is_file():
70
- existing = [x.strip() for x in registry.read_text(errors='replace').splitlines()]
71
- if line in existing:
72
- return
73
- header = [] if existing else ['# forge run dirs for ' + str(Path(repo).resolve())]
74
- # O_NONBLOCK: a FIFO with no reader fails at once (ENXIO) instead of blocking the
75
- # open, and anything that is not a regular file is left alone.
76
- fd = os.open(registry, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK, 0o644)
77
- with os.fdopen(fd, 'a') as handle:
78
- if not stat.S_ISREG(os.fstat(fd).st_mode):
79
- return
80
- for entry in header:
81
- handle.write(entry + '\n')
82
- handle.write(line + '\n')
128
+ with contextlib.ExitStack() as held:
129
+ try:
130
+ held.enter_context(_registry_lock(REGISTER_LOCK_WAIT_S))
131
+ except OSError:
132
+ pass # busy past the wait, or no lock file to be had: append unlocked
133
+ for _ in range(REGISTER_TRIES):
134
+ existing = []
135
+ if registry.is_file():
136
+ existing = [x.strip() for x in registry.read_text(errors='replace').splitlines()]
137
+ if line in existing:
138
+ return
139
+ header = [] if existing else ['# forge run dirs for ' + str(Path(repo).resolve())]
140
+ # O_NONBLOCK: a FIFO with no reader fails at once (ENXIO) instead of blocking the
141
+ # open, and anything that is not a regular file is left alone.
142
+ fd = os.open(registry, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK, 0o644)
143
+ with os.fdopen(fd, 'a') as handle:
144
+ written = os.fstat(fd)
145
+ if not stat.S_ISREG(written.st_mode):
146
+ return
147
+ handle.write(''.join(entry + '\n' for entry in header + [line]))
148
+ handle.flush()
149
+ # Still the registry: the line is in it, or in the old file an unregister copies
150
+ # from once it has replaced it. Replaced already: the line may be in neither.
151
+ try:
152
+ current = os.lstat(registry)
153
+ except FileNotFoundError:
154
+ continue
155
+ if (current.st_dev, current.st_ino) == (written.st_dev, written.st_ino):
156
+ return
83
157
  except (OSError, ValueError, RuntimeError):
84
158
  # ValueError: a NUL byte in a path; RuntimeError: a symlink loop (pathlib before 3.13)
85
159
  pass
86
160
 
87
161
 
162
+ def _rewrite_without(path: Path, wanted: set) -> bool:
163
+ """Atomically drop the lines of one registry file that name a path in ``wanted``.
164
+
165
+ Every other byte is kept as it was. Returns whether the file changed. A registry file
166
+ that is not a regular file is skipped, and read without blocking.
167
+
168
+ The caller holds the registry lock, but a register that gave up waiting for it appends
169
+ anyway, possibly to this file after it was read. So the old file stays open across the
170
+ replace, and whatever it gained since the read is appended to the new one.
171
+ """
172
+ try:
173
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
174
+ except OSError as error:
175
+ if error.errno in (errno.ENOENT, errno.ELOOP):
176
+ return False
177
+ raise
178
+ with os.fdopen(fd, 'rb') as old:
179
+ info = os.fstat(fd)
180
+ if not stat.S_ISREG(info.st_mode):
181
+ return False
182
+ data = old.read()
183
+ lines = data.splitlines(keepends=True)
184
+ kept = [line for line in lines if os.fsdecode(line).strip() not in wanted]
185
+ if len(kept) == len(lines):
186
+ return False
187
+ content = b''.join(kept)
188
+ temporary = None
189
+ try:
190
+ out, temporary = tempfile.mkstemp(dir=str(path.parent), prefix='.' + path.name + '.', suffix='.tmp')
191
+ with os.fdopen(out, 'wb') as handle:
192
+ handle.write(content)
193
+ handle.flush()
194
+ os.fsync(handle.fileno())
195
+ os.chmod(temporary, stat.S_IMODE(info.st_mode))
196
+ os.replace(temporary, path)
197
+ temporary = None
198
+ finally:
199
+ if temporary is not None:
200
+ try:
201
+ os.unlink(temporary)
202
+ except OSError:
203
+ pass
204
+ # An append that comes later still is register's to notice: the path no longer
205
+ # names the file it wrote.
206
+ late = old.read()
207
+ if late:
208
+ extra = [line for line in late.splitlines(keepends=True) if os.fsdecode(line).strip() not in wanted]
209
+ if extra:
210
+ if content and not content.endswith(b'\n'):
211
+ extra.insert(0, b'\n')
212
+ out = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK)
213
+ with os.fdopen(out, 'ab') as handle:
214
+ handle.write(b''.join(extra))
215
+ return True
216
+
217
+
218
+ def unregister_run_dir(run_dir) -> list:
219
+ """Remove a run dir from every registry file that lists it; return the files rewritten.
220
+
221
+ The reverse of ``register_run_dir``, for ``forge runs rm``, so unlike it this reports
222
+ failure: it raises OSError (BlockingIOError when the registry lock stays busy) and the
223
+ caller says the registry needs repair. Each file is rewritten atomically under the
224
+ registry lock, so a concurrent register loses nothing. ``run_dir`` need not exist any
225
+ more: entries were written resolved, so the path is resolved the same way, and the
226
+ path as given is matched too.
227
+ """
228
+ directory = registry_dir()
229
+ if not directory.is_absolute() or not directory.is_dir():
230
+ return []
231
+ wanted = {os.path.realpath(run_dir), os.path.abspath(run_dir)}
232
+ changed = []
233
+ with _registry_lock(UNREGISTER_LOCK_WAIT_S):
234
+ for path in sorted(directory.glob('*.txt')):
235
+ if _rewrite_without(path, wanted):
236
+ changed.append(path)
237
+ return changed
238
+
239
+
88
240
  # A path, not a file's worth of data: the first line of <run-dir>/repo is all that is read.
89
241
  _REPO_LINE_MAX = 64 * 1024
90
242
 
package/ui/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # forge-ui
2
2
 
3
- forge-ui is a small, read-only local web UI for forge runs. It draws them the way
3
+ forge-ui is a small local web UI for forge runs. It reads everything, and changes things only by
4
+ calling forge's own commands (see [Controls](#controls)). It draws them the way
4
5
  [archivision](https://github.com/Giamme/archivision) draws a codebase: a 3D isometric map of
5
6
  blocks on a plate, with cables between them and lamps that light up as things happen.
6
7
 
7
8
  - **A map of every run**, one district per repo and one block per run, with a lamp per task.
8
9
  Filter it by state, repo, kind, model, age or review verdict; what does not match fades
9
10
  instead of disappearing.
10
- - **A run, as planned and as it ran.** *Planned waves* puts each task in its wave, with
11
+ - **A run, as planned and as it ran.** *Plan* puts each task in its planned wave, with
11
12
  dependency cables, and ends in a *finish* line of three nodes: integration, verification
12
13
  and the whole-run **final review**. *Execution* lays the same work out on wall-clock time:
13
14
  one block per attempt, in the lane it really ran in, with its dwarf and review inside, the
@@ -26,12 +27,13 @@ blocks on a plate, with cables between them and lamps that light up as things ha
26
27
  with the primary and how sure Clef was that each task would pass.
27
28
  - **Beyond one run:** a full-screen **run cloud** of every run, grouped by project, in five
28
29
  layouts with filters and runs you can drag anywhere; **diffs** with each finding under the
29
- line it names; a **model leaderboard**; a **files heatmap**; **Fractal** child-task trees;
30
+ line it names; each dwarf's and reviewer's **conversation**, read from its CLI's log; a
31
+ **model leaderboard**; a **files heatmap**; **Fractal** child-task trees;
30
32
  **quick jump** and **notifications**; and **export** of a run as one HTML file. See
31
33
  [What you see](#what-you-see).
32
34
 
33
35
  <p align="center">
34
- <img src="docs/media/hero.webp" width="860" alt="A decomposed run on its 3D plate: eight tasks in their planned waves, then the same run on wall-clock time, where two first attempts that failed review show in red beside the retries that passed.">
36
+ <img src="docs/media/hero.webp" width="860" alt="A decomposed run on its 3D plate: eight tasks in their planned waves with a mark for each review finding, then the same run on wall-clock time, where two first attempts that failed review show in red beside the retries that passed.">
35
37
  </p>
36
38
 
37
39
  The 3D view loads three.js from a CDN the first time it is used (see [Privacy](#privacy)).
@@ -105,11 +107,21 @@ with the first source winning. Discovery and disk reads repeat on each API reque
105
107
 
106
108
  ## What you see
107
109
 
110
+ forge-ui looks like a signal box's route-setting desk: the run is drawn on a panel, every heading
111
+ is an engraved plate, every switch is a push button whose lamp lights when it is on, and lamps
112
+ keep one meaning everywhere (amber running, green merged or passed, red failed, unlit pending).
113
+
114
+ - **Layout:** the *Runs*, *Inspector* and *Tasks* modules sit around the stage. *Immersive* (the
115
+ default) floats them over a stage that fills the window; *Workbench* docks them beside it, and
116
+ the runs list folds to a rail of lamps. Switch with the two layout keys in the header; the
117
+ module keys beside them, or `1`, `2` and `3`, fold and open the runs, the inspector and the
118
+ tasks. On your first visit the inspector and the tasks start folded, so a run opens on its plate. Drag a module's inner edge, or focus it and use the arrow keys, to resize it. The header
119
+ also switches the theme (system, light or dark). All of this is remembered in this browser.
108
120
  - **Map (`#/`):** every discovered run, newest first, grouped by repo. By default the last
109
- 60 runs from the past 14 days; *older runs too* shows the rest. The sidebar list follows
110
- the same filter.
121
+ 60 runs from the past 14 days; *older runs too* shows the rest. The runs list follows the same
122
+ filter, and sorts by latest activity, state or repo.
111
123
 
112
- <img src="docs/media/map.png" width="760" alt="The map: one block per run, in a district per repo, green when done, blue while running, red with failures.">
124
+ <img src="docs/media/map.png" width="760" alt="The map: one block per run, in a district per repo, green when done, amber while running, red with failures.">
113
125
  - **Run cloud (`#/cloud`):** every run at once, full screen, floating in 3D and grouped by project
114
126
  (repo). Pick a layout: *Helix* (one spiral per project, oldest at the bottom), *Sphere*,
115
127
  *Galaxy* (a disc per project, newest at the centre), *Timeline* (left to right by start time,
@@ -128,12 +140,38 @@ with the first source winning. Discovery and disk reads repeat on each API reque
128
140
  filtered runs and offers *Load 3D*.
129
141
 
130
142
  <img src="docs/media/cloud.webp" width="760" alt="The run cloud orbited, then laid out as galaxies and as a timeline with one lane per project.">
131
- - **Run (`#/runs/<key>`):** a header (goal, repo, counts, and *Run details*: settings, request,
132
- waves, verification output), a toolbar (*Planned waves* / *Execution*, *3D* / *2D*, height
133
- and colour overlays, *Critical path*, *Review marks*, *Projected*, *Replay*), the stage with
134
- the inspector beside it, and the task table.
135
-
136
- <img src="docs/media/run.png" width="49%" alt="Planned waves: a run's tasks on a 3D plate with dependency cables and the finish nodes, beside the run's inspector."> <img src="docs/media/execution.png" width="49%" alt="Execution: one block per attempt on wall-clock time, failed attempts in red, with the timeline and critical path in the inspector.">
143
+ - **Run (`#/runs/<key>`):** a header with the goal, the repo, a describer (each state's count,
144
+ the wall time, how long since anything moved), a bank of push buttons (*Plan* /
145
+ *Execution* / *Files*, *3D* / *2D*, and *View*: height and colour overlays, the *Layers* that
146
+ switch each part of the 3D drawing on or off, and the *Marks*: *Critical path*, *Review marks*,
147
+ *Projected*) and the replay's time base; then the stage, the inspector and the
148
+ task table. The stage frames the run in the part of the window the modules leave free. The
149
+ inspector opens when you choose a task, an attempt or a finish node; with
150
+ nothing chosen it shows the run's timeline, finish and record (*Run details* in the header:
151
+ settings, request, waves, verification output).
152
+ - **Stage (3D):** each review finding stands over its task's block as a mark shaped by its
153
+ severity (P0 spike, P1 diamond, P2 cone, P3 pin, a ring when it has none; a plausible one smaller
154
+ and paler); on an opened task it moves onto the review that raised it, and the final review's sit
155
+ on *Final review*. Hover one for its location and summary; click it to open the diff that
156
+ review read at that finding. Choosing a task keeps its route lit, what it waits on and what
157
+ waits on it (`I` switches this off). Hovering a task shows its card: status, attempts, busy
158
+ time, tokens, models, its worst finding and what waits on it. A block whose roof text is too
159
+ small to read gets its name on a tag that faces you. A merge sends a pulse to *Integration*, a
160
+ failed attempt a red ring across the plate, an escalation a hop with the two models, a final
161
+ review a ring in its verdict's colour, live and during a replay (with reduced motion the block
162
+ lights instead). The keys at the stage's edge fit what is shown (`F`), zoom (`+` `−`), change
163
+ the projection (`V`: perspective, true isometric, top-down), reset the view (`0`), open the
164
+ legend of what the stage draws now (`L`), go full screen, and list every key (`?`). The layers,
165
+ the projection and the route are remembered.
166
+ - **Lamps:** green merged or passed, amber running, red failed, unlit pending. A hollow lamp is
167
+ that family's other case: passed or accepted without a merge (green), interrupted (amber), an
168
+ infrastructure failure, a timeout or blocked behind a failure (red). Review findings are never
169
+ a lamp: their severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin).
170
+ - **Tables:** the task table and the leaderboard's tables sort by any column (click its header:
171
+ ascending, descending, off), reorder by dragging a header (or Alt+← → on it), and show or hide
172
+ columns from *Columns*, which also resets them. Each table remembers its columns.
173
+
174
+ <img src="docs/media/run.png" width="49%" alt="Planned waves: a run's tasks on a 3D plate with dependency cables, finding marks and the finish nodes, between the runs list and the run's inspector."> <img src="docs/media/execution.png" width="49%" alt="Execution: one block per attempt on wall-clock time, failed attempts in red, with the timeline and critical path in the inspector.">
137
175
  - **Inspector:** for the run, its timeline, critical path, peak concurrency and finish line;
138
176
  for a task, its models and routing, escalations, timings (busy, model time, overhead,
139
177
  queue wait after its dependencies merged), attempts and their executions, review findings
@@ -147,7 +185,7 @@ with the first source winning. Discovery and disk reads repeat on each API reque
147
185
  most tasks, and hide any kind of mark; a cell selects its task and offers that file's diff.
148
186
  Below: overlaps, drift, merge conflicts and deferrals as lists.
149
187
 
150
- <img src="docs/media/files.png" width="560" alt="The files heatmap: files by directory against tasks by wave, with planned, touched, drift and same-wave overlap marks.">
188
+ <img src="docs/media/files.png" width="760" alt="The files heatmap: files by directory against tasks by wave, with planned, touched, drift and same-wave overlap marks.">
151
189
  - **Models (`#/models`):** a leaderboard of the models forge used. Implementers: tasks, first-attempt
152
190
  pass rate with its 95% Wilson interval, censored attempts, retries, escalations away from the
153
191
  model, timeouts, gate and human accepts, median model time and tokens. Reviewers: primary,
@@ -158,22 +196,35 @@ with the first source winning. Discovery and disk reads repeat on each API reque
158
196
  run cloud filtered to its runs.
159
197
 
160
198
  <img src="docs/media/models.png" width="760" alt="The implementers table: tasks, first-attempt pass rate with its 95% interval, retries, escalations, timeouts, accepts, median model time and tokens per model.">
161
- - **Diffs (`…/tasks/<id>/diff[/<source>][/file/<path>]`, `…/final-review/<name>/diff`):** a task's
199
+ - **Diffs (`…/tasks/<id>/diff[/<source>][/file/<path> | /finding/<id>]`, `…/final-review/<name>/diff[/finding/<id>]`):** a task's
162
200
  `changes.diff`, its previous or committed diff, or exactly the diff one reviewer read (the last
163
201
  diff in that review's prompt), picked from a row of sources with each review's verdict; a final
164
202
  review's combined diff. Files are listed with their +/− counts and how many findings point
165
203
  into them; each finding is shown under the line it names, those on a line outside the changed
166
204
  hunks at the top of their file, and those whose file is not in the diff (or that name no
167
205
  location) in a section of their own. n and p move between findings, j and k between files.
168
- Open it from a task, an attempt's review, an execution or the final review in the inspector.
206
+ Open it from a task, an attempt's review, an execution or the final review in the inspector, or
207
+ from a finding's mark on the stage (`/finding/<id>` scrolls to that finding and focuses it).
169
208
 
170
209
  <img src="docs/media/diff.png" width="760" alt="The diff a reviewer read, with a P1 finding under the line that compares token digests and a P2 finding under the revoke function.">
210
+ - **Conversations (`…/tasks/<id>/executions/<dwarf-or-qa>/conversation`, `…/executions/<id>/conversation`):**
211
+ what one dwarf or reviewer said and did, read from its agent CLI's `<role>.log`, in the diff's
212
+ place: its messages, its thinking folded, each command with its exit and output, each edit with
213
+ its patch and a link to the file in the diff its review read, other tool calls, and the final
214
+ answer with the tokens and cost. Search it (`/`), keep only what it said, ran or changed, and
215
+ step through its errors (`E`). While the agent works the log is read again every few seconds and
216
+ *Follow* keeps the newest event in view. codex, grok, claude, openclaude and opencode logs are
217
+ read event by event; when a claude run logged only its final answer, the conversation comes from
218
+ Claude's own session file for that run, if it is still on this machine; any other log is shown
219
+ as it is. Open it from an attempt's dwarf or reviewer in the inspector.
220
+
221
+ <img src="docs/media/conversation.png" width="760" alt="A dwarf's conversation: its thinking, the files it wrote, a test run that failed with exit 1 and the run that passed after the fix.">
171
222
  - **Fractal child tasks:** when a run used Fractal, a task's inspector shows the tree it was split
172
223
  into (status, difficulty, model, phase, cost, the split decision), and the opened task in 3D
173
224
  carries the same tree floating above it. A node (`…/tasks/<id>/fractal/<node>`) shows its goal,
174
225
  paths, timings, cost and tokens, its decomposition and the children it admitted, its last
175
226
  messages, and its candidate diff. Fractal's state is read under `$XDG_STATE_HOME/forge/fractal`
176
- (default `~/.local/state`), read-only, without following symlinks.
227
+ (default `~/.local/state`), only read, without following symlinks.
177
228
  - **Export:** *Export…* in a run's header downloads the run as one HTML file that opens in any
178
229
  browser without forge-ui, offline included (in 2D; in 3D when cdn.jsdelivr.net is reachable).
179
230
  It asks each time: by default prompts, reviewer reports, logs, finding details and diffs are
@@ -192,14 +243,47 @@ with the first source winning. Discovery and disk reads repeat on each API reque
192
243
  while the tab is in the background, and a hidden tab checks the run list every 30 seconds
193
244
  (browsers may check less often after a while). Open tabs share one log and notify once. Runs
194
245
  that have not changed for two hours never notify.
195
- - **Keys on the stage:** ← → move between tasks, Esc closes, `e` switches plan and
196
- execution. Drag to orbit, right-drag to pan, scroll to zoom once the stage is clicked.
246
+ - **Keys on the stage:** with the stage focused, ← → move between tasks (on the map, between
247
+ runs; ↑ ↓ between repos, Enter opens the run), Esc closes, `e` switches plan and execution.
248
+ Anywhere on the page while the stage shows: `F`, `+` `−`, `V`, `0`, `L`, `I`, Shift+F (full
249
+ screen) and `?`. Drag to orbit, right-drag to pan, scroll to zoom once the stage is clicked.
197
250
 
198
251
  URLs such as `http://127.0.0.1:8765/#/runs/<key>/tasks/<id>` are shareable within the
199
252
  machine while this server is running and discovering that run dir. *Live refresh* pauses
200
253
  polling; a hidden tab polls only the run list, every 30 seconds, and only with browser
201
254
  notifications on. Failed requests keep the last good data and show a banner.
202
255
 
256
+ ## Controls
257
+
258
+ The page can change a run, and every change is one call of a forge CLI; the server writes
259
+ nothing itself, so a change is validated, locked and logged exactly as if typed in a terminal
260
+ (`control.log` records `ui` as the actor). Each change is confirmed first and says when it
261
+ takes effect. The routes are in [docs/api.md](docs/api.md).
262
+
263
+ - **Run and task controls (`forge control`):** in a plan's inspector, the QA threshold,
264
+ timeout, escalate-after and verify retries, and while a scheduler is live the retry and
265
+ infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
266
+ exempts it from the run's threshold), its own retry budget and a **model pin**: the model
267
+ 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
+ - **Open findings (`forge issues`):** the findings a run, or every run of its repository,
270
+ left open. The list opens from a quick offline listing and then fills in what is already
271
+ filed; select findings to file as GitHub issues, or dismiss them (and undo that).
272
+ - **Delete a run (`forge runs rm`):** a dry run first, then the deletion. Its traces stay in
273
+ the dataset; an archive is written unless you decline it; `worktrees` and `branches` remove
274
+ the run's clean worktrees and its branches that nothing has checked out. Foreign or dirty
275
+ worktrees are listed and left.
276
+ - **Settings (`forge config`):** the registry, Jev, the repository's `.forge` files, the
277
+ dataset and `FORGE_*` variables (secrets shown as `<set>`), with edits for `repo.verify`,
278
+ `repo.setup`, `repo.verify-subset` and `jev.enabled`.
279
+
280
+ **Protection.** Each server start makes a random token and serves it in the page; every change
281
+ must send it, from a `Host` of `127.0.0.1` or `localhost`, with an `Origin` equal to the host
282
+ when one is sent, as JSON of at most 64 KiB, or it is refused before any CLI runs. There are no
283
+ CORS headers. One write per run at a time (`409 busy`). `--read-only` or `FORGE_UI_READONLY=1`
284
+ turns all of it off: no token is issued, every change is a 405, and the controls say the page cannot change the run.
285
+ `forge ui open` never reuses a server running in the other mode.
286
+
203
287
  ## Layout of the code
204
288
 
205
289
  - `forge_ui/reader.py`: discover runs, parse their files and produce summaries and details.
@@ -210,12 +294,18 @@ notifications on. Failed requests keep the last good data and show a banner.
210
294
  - `forge_ui/__main__.py`: CLI options, local startup and optional browser opening.
211
295
  - `forge_ui/__init__.py`: package version and the build id `forge ui` compares to find a stale server.
212
296
  - `forge_ui/static/index.html`, `style.css`, `app.js`: the page shell, styles, routes, views and polling.
297
+ - `forge_ui/static/theme.js`, `shell.js`, `icons.js`, `datatable.js`: the theme switch, the Immersive and
298
+ Workbench layouts with their modules, keys and splitters, the icon set, and the sortable tables
299
+ (their pure parts are node-tested). `D-DINCondensed-Bold.woff2` is the engraved labels' face.
213
300
  - `forge_ui/static/runmodel.js`: the pure run model (layouts, schedule projection, critical path, replay).
214
301
  - `forge_ui/static/panel.js`, `gantt.js`, `graph.js`: the inspector, the execution chart and strip, the 2D graph.
215
- - `forge_ui/static/map3d.js`, `scene3d.js`, `iso.js`: the 3D loader, scenes and engine (ported from archivision).
302
+ - `forge_ui/static/map3d.js`, `scene3d.js`, `iso.js`: the 3D loader, scenes and engine (ported from archivision);
303
+ `findings3d.js` the findings' marks, `stagectl.js` the stage's keys, legend and keys sheet.
216
304
  - `forge_ui/static/cloudmodel.js`, `cloud3d.js`, `cloudui.js`: the run cloud's pure model (projects,
217
305
  filters, layouts, layout files), its scene on the shared stage, and its controls.
218
306
  - `forge_ui/static/diffview.js`: the diff viewer, with findings pinned to their lines.
307
+ - `forge_ui/static/convo.js`, `forge_ui/transcripts.py`: an execution's conversation, and the
308
+ reader of each agent CLI's log format behind it.
219
309
  - `forge_ui/static/files.js`: the files heatmap (`matrix()` is pure and node-tested).
220
310
  - `forge_ui/export.py`: a run as one HTML file (the page's scripts and styles inlined, the API
221
311
  responses it reads, the 3D stage's modules as one module text, a hash-based policy).
@@ -267,7 +357,7 @@ skips otherwise. Its 3D tests need three.js: the CDN, or a local copy of the npm
267
357
 
268
358
  The pictures in this README and forge's come from `tools/readme_media.py`. After a change you
269
359
  can see, run it again: it builds the demo runs, serves them, and captures each view in
270
- Chromium with Playwright (three.js as for the browser tests; ffmpeg with libwebp and ImageMagick
360
+ Chromium with Playwright (three.js as for the browser tests; libwebp's `img2webp` and ImageMagick
271
361
  to encode the animations and PNGs). `--only cloud diff` retakes some of them.
272
362
 
273
363
  ```sh
@@ -277,8 +367,11 @@ python3 tools/readme_media.py # writes docs/media/
277
367
  ## Privacy
278
368
 
279
369
  Run dirs can contain prompts, diffs and review text from private repos. The server binds
280
- only `127.0.0.1`, makes no network requests, and writes nothing to run dirs, repositories or
281
- forge's config. Anyone with access to the local server can read the runs it discovers, and
370
+ only `127.0.0.1` and makes no network requests. It writes nothing itself; a change you
371
+ confirmed in the page is a call of forge's own CLI, protected by a per-launch token
372
+ (`--read-only` turns that off). Outside the runs it reads one kind of file: for a claude execution whose log
373
+ holds only its final answer, that run's session file, `<session id>.jsonl` under
374
+ `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects/`). Anyone with access to the local server can read the runs it discovers, and
282
375
  forge now registers every run, so that is every run on the machine.
283
376
 
284
377
  The 3D view loads three.js 0.160.0 from `cdn.jsdelivr.net` the first time it is shown in a
@@ -67,3 +67,111 @@ SOFTWARE.
67
67
 
68
68
  three.js 0.160.0 (MIT, Copyright © 2010-2023 three.js authors) is not part of forge. The page
69
69
  loads it, with its `examples/jsm` add-ons, from `cdn.jsdelivr.net` when the 3D view is used.
70
+
71
+ ## D-DIN Condensed (font)
72
+
73
+ `forge_ui/static/D-DINCondensed-Bold.woff2`, unmodified, is the D-DIN Condensed Bold face by Datto Inc.
74
+ (https://www.datto.com/fonts/d-din), used for forge-ui's engraved labels. It is licensed under the SIL Open
75
+ Font License 1.1; "D-DIN" and "D-DIN Condensed" are Reserved Font Names.
76
+
77
+ ```
78
+ Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din,
79
+ with Reserved Font Names "D-DIN".
80
+
81
+ Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din,
82
+ with Reserved Font Names "D-DIN Condensed".
83
+
84
+ Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din,
85
+ with Reserved Font Names "D-DIN Expanded".
86
+
87
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
88
+ This license is copied below, and is also available with a FAQ at:
89
+ http://scripts.sil.org/OFL
90
+
91
+ -----------------------------------------------------------
92
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
93
+ -----------------------------------------------------------
94
+
95
+ PREAMBLE
96
+ The goals of the Open Font License (OFL) are to stimulate worldwide
97
+ development of collaborative font projects, to support the font creation
98
+ efforts of academic and linguistic communities, and to provide a free and
99
+ open framework in which fonts may be shared and improved in partnership
100
+ with others.
101
+
102
+ The OFL allows the licensed fonts to be used, studied, modified and
103
+ redistributed freely as long as they are not sold by themselves. The
104
+ fonts, including any derivative works, can be bundled, embedded,
105
+ redistributed and/or sold with any software provided that any reserved
106
+ names are not used by derivative works. The fonts and derivatives,
107
+ however, cannot be released under any other type of license. The
108
+ requirement for fonts to remain under this license does not apply
109
+ to any document created using the fonts or their derivatives.
110
+
111
+ DEFINITIONS
112
+ "Font Software" refers to the set of files released by the Copyright
113
+ Holder(s) under this license and clearly marked as such. This may
114
+ include source files, build scripts and documentation.
115
+
116
+ "Reserved Font Name" refers to any names specified as such after the
117
+ copyright statement(s).
118
+
119
+ "Original Version" refers to the collection of Font Software components as
120
+ distributed by the Copyright Holder(s).
121
+
122
+ "Modified Version" refers to any derivative made by adding to, deleting,
123
+ or substituting -- in part or in whole -- any of the components of the
124
+ Original Version, by changing formats or by porting the Font Software to a
125
+ new environment.
126
+
127
+ "Author" refers to any designer, engineer, programmer, technical
128
+ writer or other person who contributed to the Font Software.
129
+
130
+ PERMISSION & CONDITIONS
131
+ Permission is hereby granted, free of charge, to any person obtaining
132
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
133
+ redistribute, and sell modified and unmodified copies of the Font
134
+ Software, subject to the following conditions:
135
+
136
+ 1) Neither the Font Software nor any of its individual components,
137
+ in Original or Modified Versions, may be sold by itself.
138
+
139
+ 2) Original or Modified Versions of the Font Software may be bundled,
140
+ redistributed and/or sold with any software, provided that each copy
141
+ contains the above copyright notice and this license. These can be
142
+ included either as stand-alone text files, human-readable headers or
143
+ in the appropriate machine-readable metadata fields within text or
144
+ binary files as long as those fields can be easily viewed by the user.
145
+
146
+ 3) No Modified Version of the Font Software may use the Reserved Font
147
+ Name(s) unless explicit written permission is granted by the corresponding
148
+ Copyright Holder. This restriction only applies to the primary font name as
149
+ presented to the users.
150
+
151
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
152
+ Software shall not be used to promote, endorse or advertise any
153
+ Modified Version, except to acknowledge the contribution(s) of the
154
+ Copyright Holder(s) and the Author(s) or with their explicit written
155
+ permission.
156
+
157
+ 5) The Font Software, modified or unmodified, in part or in whole,
158
+ must be distributed entirely under this license, and must not be
159
+ distributed under any other license. The requirement for fonts to
160
+ remain under this license does not apply to any document created
161
+ using the Font Software.
162
+
163
+ TERMINATION
164
+ This license becomes null and void if any of the above conditions are
165
+ not met.
166
+
167
+ DISCLAIMER
168
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
169
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
170
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
171
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
172
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
173
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
174
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
175
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
176
+ OTHER DEALINGS IN THE FONT SOFTWARE.
177
+ ```