@giamme/forge 0.0.0-stage → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
package/ui/docs/api.md ADDED
@@ -0,0 +1,888 @@
1
+ # forge-ui data contract
2
+
3
+ This file is the contract between the three parts of forge-ui. It is fixed for tasks and
4
+ dwarves: implement it as written, and do not edit it as part of a task. Its owner revised it
5
+ deliberately for 0.3.0 (executions, findings, final reviews, the lazy endpoints and the 3D view)
6
+ and for 0.4.0 (diffs with pinned findings, the files heatmap, model facts and Fractal child tasks).
7
+
8
+ ```
9
+ forge_ui/reader.py reads forge run dirs from disk, returns plain dicts (no I/O beyond reads)
10
+ forge_ui/executions.py the attempts/<role>-<uuid>/ dirs: parsing, nesting, attempt numbers, a parse cache
11
+ forge_ui/findings.py QA findings and gate lines, parsed by scripts/forge-contract.py
12
+ forge_ui/diffs.py unified diffs: parsing, the ```diff fence of a review prompt, pinning findings, cached reads
13
+ forge_ui/models.py per-run model facts (attempts and reviews) for the leaderboard, cached per run
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
16
+ forge_ui/static/ the page: index.html, app.js, style.css, graph.js and the scripts they load
17
+ ```
18
+
19
+ Python 3.9+ standard library only. No third-party packages, no build step. The server makes
20
+ 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
+
24
+ ## What a forge plan dir looks like
25
+
26
+ A decomposed forge run lives in a *plan dir*. Everything is optional except `tasks.tsv`;
27
+ any file may be missing, empty, truncated or malformed (runs are read while they are being
28
+ written), and the reader must never raise because of one.
29
+
30
+ | Path | Format |
31
+ |---|---|
32
+ | `tasks.tsv` | tab-separated, 7 columns: `id deps difficulty files dwarf qa title`. Lines starting with `#` and blank lines are skipped. `deps` and `files` are comma lists or `-`. `dwarf`/`qa` are model specs (`alias[:effort[:harness]]`), `-` (not yet resolved) or `UNASSIGNED` |
33
+ | `waves.tsv` | `wave<TAB>id` per line, wave is an integer from 1 |
34
+ | `results.tsv` | `id<TAB>status<TAB>branch` per line, e.g. `reader MERGED forge/20261016-1530/reader` |
35
+ | `run_id` | one line, e.g. `20261016-153012-ab12` |
36
+ | `repo` | one line, absolute path of the target repo |
37
+ | `goal.txt`, `request.md` | free text |
38
+ | `decompose_level` | `low`, `medium` or `high` |
39
+ | `explore`, `shadow_rate` | one number each (e.g. `0.5`) |
40
+ | `shadow_qa` | a comma list of specs |
41
+ | `timeout` | seconds |
42
+ | `verification.status` | `PASS`, `FAIL` or another word; `verification.command` the command; `verification.log` its output |
43
+ | `verification.runs.tsv` | `n<TAB>kind<TAB>exit<TAB>tree_changed<TAB>duration_s<TAB>logfile` per run of the command; `kind` is `initial`, `rerun` or `jev_retry`, `exit` may be empty, `tree_changed` is `0` or `1` |
44
+ | `verification.commit` | one line, the commit verified |
45
+ | `max_parallel`, `escalate_after`, `verify_retries` | one integer each |
46
+ | `qa_threshold` | `P0`..`P3`, the least severe finding that still blocks; `final_qa` the final review's QA spec |
47
+ | `deferrals.txt` | one line per task the wave planner deferred |
48
+ | `integration.base`, `accepted.integration` | one commit each: where the integration branch started, the revision the run accepted |
49
+ | `attempts/<role>-<uuid>/` | one *execution* (below) of the run itself: `parallel` (a `run`) or `verification` |
50
+ | `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>/` |
51
+ | `final-review` | a symlink to the newest *published* review dir; `final-review.verdict` a copy of its verdict |
52
+ | `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 |
53
+ | `tasks/<id>/merged` | exists when the task was merged; its mtime is when |
54
+ | `tasks/<id>/status` | one word: `RUNNING PASS FAIL ERROR INFRA TIMEOUT UNKNOWN INVALIDATED BLOCKED INTERRUPTED NOCHANGES CONFLICT` |
55
+ | `tasks/<id>/attempt` | the current attempt number |
56
+ | `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 |
58
+ | `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
59
+ | `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
60
+ | `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value` lines in current forge; returned as text) |
61
+ | `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) |
62
+ | `tasks/<id>/known_issues.md` | the confirmed findings a threshold tolerated |
63
+ | `tasks/<id>/dwarf.input`, `qa.input` | the prompts forge assembled; `<role>.delivered`, `<role>.resolved` the live dispatch files |
64
+ | `tasks/<id>/attempts/<role>-<uuid>/` | one execution of the task: `task`, `dwarf` or `qa` |
65
+ | `tasks/<id>/changes.diff` | `git diff --binary` of the task's work since its base, cumulative, rewritten every attempt; `changes.prev.diff` the previous attempt's; `committed.diff` base..the reviewed commit |
66
+ | `tasks/<id>/drift.txt`, `conflict.files` | one path per line: files the diff changed without declaring them; files that conflicted on merge |
67
+ | `attempts/qa-<uuid>/qa.prompt` | the copy of the prompt a reviewer read; the diff it reviewed is its **last** fenced block: a line exactly ` ```diff `, the diff, a line exactly ` ``` ` (a section may follow) |
68
+ | `final-review-*/combined.diff` | the diff a final review read, absent when larger than 400 KB (then not inlined in `qa.input`) |
69
+ | `fractal-selection.json` | `{"enabled": true, "run": "run-<20 hex>"}` (or `enabled: false`): the run's Fractal choice, see Fractal below |
70
+ | `tasks/<id>/route.jsonl` | one JSON object per line, one per routing decision: `at` (epoch), `task`, `tier`, `epsilon`, `candidates` (list of specs), `default` (spec), `chosen` (spec), `explored` (bool), `propensity` (probability the chosen spec had), `inputs` (object), `decision` (a short hash identifying the decision). Keep unknown fields |
71
+ | `tasks/<id>/route.explored` | exists when the task's route was an exploratory pick |
72
+ | `tasks/<id>/clef.first_pass` | JSON: `p` (predicted probability the first attempt passes), `model`, `at`, `plan_table`, `assignment` (`{dwarf, qa}`) |
73
+ | `tasks/<id>/clef.retry_pass.<n>` | JSON, same shape plus `attempt` = n |
74
+ | `shadow/<id>/<attempt>-<k>/shadow.json` | JSON: `task`, `attempt`, `reviewed`, `primary`, `spec`, `pick`, `started`. A second, never-read review of attempt `<attempt>` |
75
+ | `shadow/<id>/<attempt>-<k>/shadow.status` | one word |
76
+ | `shadow/<id>/<attempt>-<k>/qa.last` | the shadow reviewer's final message, verdict as above |
77
+ | `shadow/<id>/<attempt>-<k>/attempts/qa-<uuid>/` | the shadow review's execution |
78
+
79
+ An **execution** is one dir `attempts/<role>-<uuid>/` that forge-runtime.py writes, `<role>`
80
+ one of `task dwarf qa solo verification parallel` and `<uuid>` 32 lowercase hex digits:
81
+
82
+ | Path | Format |
83
+ |---|---|
84
+ | `metrics.json` | `role`, `started` (monotonic), `wall_started` (epoch), `phase`, `phase_started` (monotonic), `elapsed_s` (seconds per phase, `total` once done), `exit_code` (`null` while in flight), `harness`, `prompt_bytes`, `input_tokens`, `cached_input_tokens`, `output_tokens`, `infra_class`, `shadow` |
85
+ | `dataset.json` | may be absent: `parent` (the execution that started this one, e.g. `task-<uuid>`), `trigger`, `attempt_start` and `resume` (task executions) |
86
+ | `<role>.resolved`, `.prompt`, `.last`, `.timedout` | copies made when it finished: the model resolution (`key=value`), the prompt, the final message, the timeout marker |
87
+
88
+ The reader never opens an execution's `.log` or `.cmd`.
89
+
90
+ **Task status** is `MERGED` if `tasks/<id>/merged` exists, otherwise the first word of
91
+ `tasks/<id>/status`, otherwise `PENDING`.
92
+
93
+ Only the ids listed in `tasks.tsv` are tasks: `tasks/` may also hold forge's own dirs (e.g.
94
+ `tasks/_integration/`), and a task dir holds many other files (`attempts/` is a directory of
95
+ receipts, not `attempts.tsv`), all of which the reader ignores.
96
+
97
+ ## What a solo run dir looks like
98
+
99
+ A solo run has no `tasks.tsv`. It is a directory whose `prompt.md` and `repo` are both regular
100
+ files. Anything else may be missing, empty, truncated or malformed, and the reader must never
101
+ raise because of one. Older solo dirs have `verdict` and not `status`.
102
+
103
+ | Path | Format |
104
+ |---|---|
105
+ | `prompt.md` | the implementation prompt |
106
+ | `repo` | one line, absolute path of the target repo |
107
+ | `goal.txt`, `request.md` | free text; the summary goal is the first line of `goal.txt` |
108
+ | `status` | one word: `RUNNING`, then `PASS`, `FAIL`, `UNKNOWN`, `NOCHANGES`, `INVALIDATED`, `TIMEOUT`, `INFRA`, `INTERRUPTED` or `ERROR` |
109
+ | `verdict` | one word, the legacy status (`PASS`, `FAIL`, `UNKNOWN`, `NOCHANGES`, `INVALIDATED`, …) |
110
+ | `dwarf.spec`, `qa.spec` | one line, a model spec (`alias[:effort[:harness]]`) |
111
+ | `dwarf.resolved`, `qa.resolved` | `key=value` lines; `alias=` and `effort=` name the model that ran |
112
+ | `attempts.tsv` | `epoch<TAB>n<TAB>status<TAB>infra class<TAB>dwarf spec`. The fifth column is recorded for forge and is not a separate attempt field |
113
+ | `attempts/<role>-<uuid>/` | the run's executions: `solo`, `dwarf`, `qa` |
114
+ | `dwarf.input`, `qa.input`, `qa.gate`, `known_issues.md` | as for a plan task |
115
+ | `dwarf.last`, `qa.last` | the role's final message. `qa.last` carries `FORGE_VERDICT:` the same way a plan task does |
116
+ | `dwarf.log`, `qa.log` | raw logs. The summary stats them and does not read them, so `updated` moves while a model writes |
117
+ | `changes.diff`, `existing.diff` | the run's change, and the uncommitted changes the repo already had when it started |
118
+ | `fractal-selection.json` | as for a plan |
119
+
120
+ **Solo status** is the first word of `status`, otherwise the first word of `verdict`, otherwise
121
+ `UNKNOWN`.
122
+
123
+ **Which dirs are runs:** `tasks.tsv` means a plan, even when `prompt.md` and `repo` are also
124
+ present. Otherwise `prompt.md` and `repo`, both regular files, means a solo run. Anything else
125
+ is not a run.
126
+
127
+ ## Where runs come from
128
+
129
+ 1. `--plan DIR` (repeatable): that dir, if it is a plan or a solo run.
130
+ 2. `--root DIR` (repeatable): every plan dir at depth 0, 1 or 2 under DIR (DIR itself,
131
+ its children, its grandchildren). Solo runs are not picked up, so a directory that merely
132
+ contains `prompt.md` is ignored. Hidden dirs (`.name`) are not descended into.
133
+ 3. forge's run registry, unless `--no-registry`: every `*.txt` file in
134
+ `$XDG_CONFIG_HOME/forge/runs/` (default `~/.config/forge/runs/`). Each file is a
135
+ `# ...` header line then one absolute path per line. Paths that no longer exist or are
136
+ not plan or solo runs are skipped silently.
137
+
138
+ Duplicates (same `os.path.realpath`) are listed once. The first source that found a dir
139
+ wins for `source`.
140
+
141
+ ## Python API (`forge_ui/reader.py`)
142
+
143
+ ```python
144
+ def run_key(path) -> str
145
+ """Stable opaque id of a run dir: sha1 of its realpath, first 12 hex chars."""
146
+
147
+ def discover(plans=(), roots=(), registry=True, registry_dir=None) -> list[tuple[Path, str]]
148
+ """(realpath, source) for every run dir found, source in 'plan' | 'root' | 'registry'.
149
+ `--plan` and the registry accept plan and solo dirs. `--root` yields plan dirs only.
150
+ registry_dir overrides the registry location (tests use it)."""
151
+
152
+ def summarize(path, source='plan') -> dict # a RunSummary, below
153
+ def detail(path, source='plan') -> dict # a RunDetail, below
154
+ def task_detail(path, task_id, source='plan') -> dict | None # a TaskDetail
155
+ def execution_detail(path, name, source='plan') -> dict | None # an ExecutionDetail
156
+ def final_review_detail(path, name, source='plan') -> dict | None # a FinalReviewDetail
157
+ def task_diff(path, task_id, which='current', source='plan') -> dict | None # a DiffDetail
158
+ def execution_diff(path, name, source='plan') -> dict | None # a DiffDetail
159
+ def final_review_diff(path, name, source='plan') -> dict | None # a DiffDetail
160
+ def files_detail(path, source='plan') -> dict | None # Files
161
+ def fractal_detail(path, task_id, source='plan') -> dict | None # a TaskFractal
162
+ def fractal_node(path, task_id, node, source='plan') -> dict | None # a FractalNodeDetail
163
+ def fractal_node_diff(path, task_id, node, source='plan') -> dict | None # a DiffDetail
164
+
165
+ class Catalog:
166
+ def __init__(self, plans=(), roots=(), registry=True, registry_dir=None): ...
167
+ def runs(self) -> list[dict] # RunSummary list, newest `updated` first
168
+ def get(self, key) -> dict | None # RunDetail for that run_key, or None
169
+ def task(self, key, task_id) -> dict | None
170
+ def execution(self, key, name) -> dict | None
171
+ def final_review(self, key, name) -> dict | None
172
+ 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),
174
+ fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
175
+ def models(self) -> dict # Models, over every run
176
+ ```
177
+
178
+ `Catalog` re-discovers and re-reads on every call: runs change while they are watched. Files
179
+ that stop changing once written (`metrics.json`, `dataset.json`, `*.resolved` and `qa.last`
180
+ copies, findings of a reply) are parsed once and kept by `(dev, inode, size, mtime_ns)`; a
181
+ file is still stat'ed on every call, and one younger than 3 seconds is never kept, since a
182
+ rewrite within one mtime tick can keep its size. Every returned value is JSON-serializable
183
+ with `json.dumps(allow_nan=False)` (no `Path`, no sets, no bytes, no NaN), and a detail read
184
+ twice from unchanged files is equal: nothing depends on the clock.
185
+
186
+ Findings are parsed by `scripts/forge-contract.py` (`parse_findings`, `_LOCATION`), loaded
187
+ once on first use. `build_id()` does not hash it: a running server keeps the parser it
188
+ loaded. When it cannot be loaded, `findings_available` is `false` and every findings field
189
+ is `null`; verdicts and `gate` do not need it.
190
+
191
+ Text fields are capped so one huge file cannot blow up a response: `request`, `prompt`,
192
+ `approach`, `retry_findings` keep their first 20,000 characters; `qa_tail`, `dwarf_tail`
193
+ and `verification.log_tail` keep their last 4,000. A capped field ends (or, for tails,
194
+ starts) with `…` and the value of `truncated` lists the field names that were cut.
195
+
196
+ The lazy details cap differently: a cut text keeps its head and its tail around `\n…\n`
197
+ (two bounded reads, never the whole file). TaskDetail `texts`, FinalReviewDetail
198
+ `tasks_summary`, `qa_last` and `known_issues` keep 20,000 + 8,000 characters; ExecutionDetail
199
+ `prompt` and FinalReviewDetail `qa_input` keep 40,000 + 20,000; ExecutionDetail `last` keeps
200
+ its last 20,000 behind a leading `…`. Their `truncated` lists the field names (`prompt`,
201
+ `qa_input`, `last`, ...). A Finding's `text` keeps 20,000 characters, its `summary` 240.
202
+
203
+ ## JSON API (`forge_ui/server.py`)
204
+
205
+ | Request | Response |
206
+ |---|---|
207
+ | `GET /api/runs` | `200 {"runs": [RunSummary, ...]}` |
208
+ | `GET /api/runs/<key>` | `200 RunDetail`, or `404 {"error": "not found"}` |
209
+ | `GET /api/runs/<key>/tasks/<id>` | `200 TaskDetail`; `<id>` is percent-decoded (strict UTF-8) and must be a `tasks.tsv` id (`solo` for a solo run). An id that decodes to `/`, NUL, U+FFFD, `.` or `..` is a 404 without a read |
210
+ | `GET /api/runs/<key>/executions/<role>-<uuid>` | `200 ExecutionDetail` |
211
+ | `GET /api/runs/<key>/final-reviews/<name>` | `200 FinalReviewDetail`; `<name>` is `final-review-<e>-<p>` or `integrate-<e>-<p>` |
212
+ | `GET /api/runs/<key>/tasks/<id>/diff[/<which>]` | `200 DiffDetail`; `<which>` is `current` (the default: `changes.diff`), `prev`, `committed` (plan tasks), `existing` (solo) |
213
+ | `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
+ | `GET /api/runs/<key>/final-reviews/<name>/diff` | `200 DiffDetail` |
215
+ | `GET /api/runs/<key>/files` | `200 Files` |
216
+ | `GET /api/models` | `200 Models` |
217
+ | `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
+ | `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
219
+ | `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
+ | `GET /` | `forge_ui/static/index.html` |
221
+ | `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` |
223
+
224
+ The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
225
+ 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
227
+ Fractal node endpoints for an unknown node or a task whose Fractal state is not `available`. A
228
+ diff that is not there yet is a `200` with `available: false`. An execution is looked for only where executions are written:
229
+ `attempts/`, `tasks/<id>/attempts/`, `shadow/<id>/<a>-<k>/attempts/`,
230
+ `final-review-*/attempts/` and `integrate-*/final-review/attempts/`, in that order; a dir
231
+ that resolves outside the run dir (a symlinked `attempts/`, say) is ignored.
232
+
233
+ JSON responses: `Content-Type: application/json; charset=utf-8`,
234
+ `Cache-Control: no-store`. The page loads `style.css`, `runmodel.js`, `graph.js`, `gantt.js`,
235
+ `panel.js`, `map3d.js` and `app.js` by those relative names, and `scene3d.js` and `iso.js`
236
+ (ES modules) only when the 3D view is used.
237
+
238
+ Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`
239
+ and exactly one `Content-Security-Policy`. An `.html` file gets the page policy
240
+
241
+ ```
242
+ default-src 'self'; style-src 'self'; script-src 'self' https://cdn.jsdelivr.net/npm/three@0.160.0/ <hashes>; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'
243
+ ```
244
+
245
+ where `<hashes>` is `'sha256-<base64>'` for each `<script type="importmap">…</script>` in the
246
+ file as served (tag case-insensitive; CRLF and CR read as LF first, as browsers do), and
247
+ nothing when there is none. Every other response keeps
248
+ `default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'`.
249
+
250
+ ## RunSummary
251
+
252
+ ```json
253
+ {
254
+ "key": "3f9a0c1d2e4b",
255
+ "path": "/Users/me/dev/forge-ui-plan",
256
+ "source": "plan",
257
+ "kind": "plan",
258
+ "run_id": "20261016-153012-ab12",
259
+ "repo": "/Users/me/dev/forge-ui",
260
+ "goal": "first line of goal.txt",
261
+ "decompose_level": "medium",
262
+ "updated": 1760621412.5,
263
+ "task_count": 5,
264
+ "counts": {"MERGED": 3, "RUNNING": 1, "PENDING": 1},
265
+ "verification_status": "PASS",
266
+ "created": 1760619000.0,
267
+ "task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs"},
268
+ {"id": "page", "status": "RUNNING", "difficulty": "medium", "title": "Draw the page"}],
269
+ "models": ["opus", "sol:xhigh"],
270
+ "dwarf_models": ["sol:xhigh"],
271
+ "qa_models": ["opus"],
272
+ "final_review": "FAIL"
273
+ }
274
+ ```
275
+
276
+ - `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty) and
277
+ `title` (its first 160 characters, then `…`). A solo run: `[{"id": "solo", "status": <solo status>,
278
+ "difficulty": null, "title": <goal or "Solo run">}]`.
279
+ - `created`: when the run began: the earliest birth time (else mtime) of the files a run writes
280
+ once at its start (plan: `run_id`, `repo`, `request.md`, `goal.txt`, `tasks.tsv`; solo: `prompt.md`,
281
+ `repo`, `request.md`, `goal.txt`), else `null`. These are stat'ed only, and never move `updated`.
282
+ - `dwarf_models`, `qa_models`: `models` split by role.
283
+ - `models`: the sorted distinct specs of the `dwarf` and `qa` columns (a solo run: its `dwarf`
284
+ and `qa` specs as its task has them), nulls left out.
285
+ - `final_review`: the first word of `final-review.verdict`, else `null`; always `null` for a
286
+ solo run. (RunDetail replaces it with an object, below.)
287
+
288
+ `kind` is `"plan"` or `"solo"`. `updated` is the newest mtime (epoch seconds, float) among
289
+ the run dir and the files the reader read. Any field it cannot determine is `null`
290
+ (`counts` is then `{}`, `task_count` 0).
291
+
292
+ For a solo run, `run_id` is the directory name, `decompose_level` and `verification_status`
293
+ are `null`, `task_count` is 1, and `counts` is `{<status>: 1}` using the solo status above.
294
+ The summary reads only `repo`, `goal.txt`, `status`, `verdict`, `dwarf.spec`, `qa.spec` and,
295
+ when a spec file is missing, `*.resolved`. It also stats `dwarf.log` and `qa.log` without
296
+ reading them, so `updated` moves while a model is writing a log. A plan summary reads
297
+ `final-review.verdict` besides the files it always read.
298
+
299
+ ## RunDetail
300
+
301
+ Every RunSummary field, plus:
302
+
303
+ ```json
304
+ {
305
+ "request": "the user's request, verbatim",
306
+ "settings": {"explore": 0.5, "shadow_rate": 0.5, "shadow_qa": ["opus", "sol:xhigh"], "timeout": 3600,
307
+ "max_parallel": 2, "qa_threshold": "P2", "escalate_after": 3, "verify_retries": 1,
308
+ "final_qa": "opus"},
309
+ "verification": {"status": "PASS", "command": "python3 -m unittest", "log_tail": "...",
310
+ "runs": [{"n": 1, "kind": "initial", "exit": 0, "tree_changed": false,
311
+ "duration_s": 12.3, "log": "verification.run-1.log"}],
312
+ "commit": "9c1e4b7a..."},
313
+ "waves": [["reader", "page", "graph"], ["server"], ["readme"]],
314
+ "tasks": [Task, ...],
315
+ "timeline": {"started": 1760620000.25, "ended": null, "runs": [Execution], "verifications": [Execution]},
316
+ "integration": {"base": "1a2b...", "accepted": "3c4d...",
317
+ "merged": [{"id": "reader", "at": 1760621100.0}],
318
+ "integrations": [{"name": "integrate-1760622000-4300", "at": 1760622000.0,
319
+ "verification_status": "PASS",
320
+ "final_review": "integrate-1760622000-4300/final-review",
321
+ "ended": 1760622300.0}]},
322
+ "final_review": FinalReviewSummary,
323
+ "final_reviews": [FinalReviewSummary, ...],
324
+ "deferrals": ["wave 2: docs deferred (file overlap with a task already in this wave)"],
325
+ "findings_available": true,
326
+ "warnings": ["tasks/graph/route.jsonl line 2: not JSON"],
327
+ "truncated": ["tasks/reader/prompt"]
328
+ }
329
+ ```
330
+
331
+ - `settings` values are `null` when the file is missing or unparsable; `shadow_qa` is a
332
+ list (empty when missing). `max_parallel`, `escalate_after`, `verify_retries` are integers,
333
+ `qa_threshold` and `final_qa` the first line of their file. A solo run has no plan
334
+ settings: every value is `null`, including `shadow_qa`.
335
+ - `verification` is `null` when none of `verification.status`, `.command`, `.log` exists. A
336
+ solo run's `verification` is always `null`. `runs` comes from `verification.runs.tsv`
337
+ (malformed rows are warned and skipped; an empty `exit` is `null`), `commit` is the first
338
+ line of `verification.commit` or `null`.
339
+ - `timeline.started` is the earliest `started` of every execution the detail shows (tasks
340
+ and their shadows, the listed final reviews, the run's own); `ended` is the latest `ended`,
341
+ or `null` while any of them is `running` or when none has ended. `runs` and
342
+ `verifications` are the run's own `parallel` and `verification` executions. A solo run's
343
+ comes from its executions, with `runs` and `verifications` empty.
344
+ - `integration` is `null` unless `integration.base`, `accepted.integration`, a merged marker
345
+ or an `integrate-*` dir exists. `merged` is every merged task with its marker's mtime,
346
+ oldest first. `integrations` is every `integrate-<e>-<p>/`, newest first: `at` is `<e>`,
347
+ `verification_status` the first word of its `verification.status`, `final_review` the
348
+ path of its review dir or `null`, `ended` the latest end of its verification and review
349
+ executions (`null` while one is unfinished or there is none).
350
+ - `final_review` is the newest FinalReviewSummary (review and integrate dirs together, by the
351
+ epoch in the name), or `null`; `final_reviews` lists the newest 20, newest first.
352
+ - `deferrals`: the non-empty lines of `deferrals.txt`, `[]` when it is missing.
353
+ - `findings_available`: whether `scripts/forge-contract.py` could be loaded.
354
+ - `fractal`: `null` without `fractal-selection.json`, else `{"enabled", "run", "available", "reason"}`
355
+ (Fractal below). Plan and solo runs alike.
356
+ - A solo run has `integration` `null`, `final_review` `null`, `final_reviews` and
357
+ `deferrals` `[]`.
358
+ - `waves` comes from `waves.tsv` (index 0 is wave 1); `[]` when it is missing. A solo run's
359
+ `waves` is `[["solo"]]`.
360
+ - `tasks` keeps the order of `tasks.tsv`. A solo run has one task, described below.
361
+ - `warnings` names every file or line the reader skipped as malformed. Missing files are
362
+ not warnings.
363
+
364
+ ## Task
365
+
366
+ ```json
367
+ {
368
+ "id": "reader",
369
+ "title": "Read plan dirs into plain dicts",
370
+ "deps": [],
371
+ "difficulty": "high",
372
+ "files": ["forge_ui/reader.py", "tests/test_reader.py"],
373
+ "dwarf": "astra",
374
+ "qa": "opus",
375
+ "wave": 1,
376
+ "status": "MERGED",
377
+ "branch": "forge/20261016-153012-ab12/reader",
378
+ "attempt": 2,
379
+ "attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
380
+ {"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
381
+ "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
382
+ "verdict": "PASS",
383
+ "accepted_by": null,
384
+ "explored": true,
385
+ "route": [{"at": 1760619000.0, "task": "reader", "tier": "high", "epsilon": 0.5,
386
+ "candidates": ["astra", "opus"], "default": "opus", "chosen": "astra",
387
+ "explored": true, "propensity": 0.25, "inputs": {}, "decision": "9c1e4b7a0d2f3e58"}],
388
+ "clef": {"first_pass": {"p": 0.71, "model": "clef", "at": 1760619100.0,
389
+ "assignment": {"dwarf": "astra", "qa": "opus"}},
390
+ "retry_pass": {"2": {"p": 0.64, "model": "clef", "at": 1760620600.0, "attempt": 2,
391
+ "assignment": {"dwarf": "sol", "qa": "opus"}}}},
392
+ "shadow": [{"attempt": 1, "k": 0, "spec": "sol:xhigh", "primary": "opus",
393
+ "status": "PASS", "verdict": "FAIL", "started": 1760620100.0}],
394
+ "prompt": "full prompt.md text",
395
+ "approach": null,
396
+ "retry_findings": "...",
397
+ "qa_tail": "last 4000 chars of qa.last",
398
+ "dwarf_tail": "last 4000 chars of dwarf.last",
399
+ "executions": [Execution, ...],
400
+ "merged_at": 1760621100.0,
401
+ "findings": FindingsSummary,
402
+ "gate": {"threshold": "P2", "decision": "keep", "worst": "P3", "n_confirmed": 1,
403
+ "n_unlabelled": 0, "n_below": 1},
404
+ "known_issues": true
405
+ }
406
+ ```
407
+
408
+ - `deps`, `files`: lists; `-` or empty becomes `[]`.
409
+ - `dwarf`, `qa`: the `tasks.tsv` value; `-`, empty and `UNASSIGNED` become `null`.
410
+ - `wave`: from `waves.tsv`, `null` if absent.
411
+ - `branch`: from `results.tsv`; else `forge/<run_id>/<id>` when `run_id` is known and the
412
+ task has a `tasks/<id>/` dir; else `null`.
413
+ - `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
414
+ else `null`.
415
+ - `infra` in an attempt: `null` for empty, `-` or missing.
416
+ - `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
417
+ - `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
418
+ plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
419
+ Capped like `qa_tail`.
420
+ - `explored`: `true` if `route.explored` exists or any `route` entry has `explored: true`.
421
+ - `clef.first_pass` is `null` when absent; `clef.retry_pass` is `{}` when there are none;
422
+ its keys are the attempt numbers as strings.
423
+ - `shadow`: one entry per `shadow/<id>/<attempt>-<k>/` dir, sorted by attempt then k.
424
+ `status` is `shadow.status`, `verdict` is parsed from that dir's `qa.last`; fields from
425
+ `shadow.json` (`spec`, `primary`, `started`, `reviewed`, `pick`, as they are) that are
426
+ missing are `null`, and a missing `started` falls back to the execution's. Each entry also
427
+ has `execution` (the id of its newest `attempts/qa-*` execution, or `null`), `ended` and
428
+ `duration_s` (that execution's), `findings` (FindingsSummary of its `qa.last`),
429
+ `primary_verdict` (the verdict of the newest non-shadow `qa` execution of the task with the
430
+ same attempt, or `null`) and `agreement`: `agree` or `disagree` when both verdicts are
431
+ PASS or FAIL, else `unknown`.
432
+ - `executions`: every execution in `tasks/<id>/attempts/` (`task`, `dwarf`, `qa`); for a solo
433
+ run, the run dir's `attempts/` (`solo`, `dwarf`, `qa`). Shadow executions are not in it.
434
+ - `merged_at`: the mtime of `tasks/<id>/merged`, or `null`.
435
+ - `findings`: FindingsSummary of the current `qa.last`, or `null`.
436
+ - `gate`: the parsed `qa.gate` (`n_*` integers, `-` is `null`), or `null` when missing or
437
+ malformed (malformed is warned).
438
+ - `known_issues`: whether `known_issues.md` is a non-empty file.
439
+ - Text fields are `null` when the file is missing.
440
+
441
+ A solo run's one task uses this same shape. `id` is `solo` and `title` is the goal, or
442
+ `"Solo run"` when `goal.txt` has no first line. `dwarf` and `qa` come from `dwarf.spec` and
443
+ `qa.spec`, otherwise `alias[:effort]` from the matching `*.resolved` (`effort` is omitted when
444
+ it is missing or `<harness default>`). `wave` is 1, `branch` is `null`, and `attempts` come
445
+ from the run dir's `attempts.tsv`. `status` is the solo status; `attempt` is the highest `n`
446
+ in `attempts.tsv`, or `null`. `escalations`, `route` and `shadow` are empty. `verdict` and
447
+ `qa_tail` come from `qa.last`, and `prompt` comes from `prompt.md`. `deps` and `files` are
448
+ `[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `explored` is
449
+ false; `clef` is `{first_pass: null, retry_pass: {}}`. `request` on the detail is `request.md`.
450
+
451
+ ## Execution
452
+
453
+ ```json
454
+ {"id": "dwarf-0f1e2d3c4b5a69788796a5b4c3d2e1f0", "role": "dwarf",
455
+ "parent": "task-9c1e4b7a0d2f3e589c1e4b7a0d2f3e58", "attempt": 2, "shadow": false,
456
+ "state": "done", "started": 1760620000.25, "ended": 1760620612.75, "duration_s": 612.5,
457
+ "phase": "complete", "phase_started": 1760620612.75,
458
+ "phases": {"preparation": 1.2, "preflight": 0.4, "model": 590.1, "snapshot": 3.3, "verification": 0.0},
459
+ "exit_code": 0, "infra_class": null, "timed_out": false,
460
+ "model": {"spec": "sol:xhigh", "alias": "sol", "harness": "codex", "model": "gpt-sol", "effort": "xhigh",
461
+ "clamped": null},
462
+ "tokens": {"input": 120000, "cached": 90000, "output": 8000}, "prompt_bytes": 45678,
463
+ "verdict": null, "findings": null}
464
+ ```
465
+
466
+ - `started` is `wall_started`; `ended` is `started + elapsed_s.total` and `duration_s` is
467
+ `elapsed_s.total`, both `null` until `exit_code` is set; `phase_started` is
468
+ `wall_started + (phase_started - started)`. `phases` keeps the numeric `elapsed_s` entries
469
+ except `total`. A value of the wrong type is dropped with a warning; a missing or invalid
470
+ `metrics.json` leaves every field it gives `null`.
471
+ - `state`: `done` once `exit_code` is an integer. Otherwise `running` when its owner is live
472
+ and it is the newest execution of its role in its dir, else `orphaned`. The owner is live
473
+ when: the task's status is `RUNNING` (task executions and their children); the solo
474
+ status is `RUNNING`; a shadow has no `shadow.status`; a final review's `state` is
475
+ `running`; always, for the run's own `parallel` and `verification`. A `dwarf` or `qa`
476
+ whose parent is not `running` is `orphaned`.
477
+ - `parent`: for `dwarf` and `qa`, the `task` (or `solo`) execution `dataset.json` names, else
478
+ the latest one whose run (`started` to `ended`, open while unfinished) contains this
479
+ start, else `null`. `task`, `solo`, `parallel` and `verification` have no parent.
480
+ - `attempt`: a `task` or `solo` execution takes the `attempts.tsv` row whose epoch is within
481
+ 120 s of its `ended` (closest first, each row once); else `dataset.json`'s
482
+ `attempt_start + 1` (`attempt_start` when `resume` is set); else, while running, the
483
+ task's `attempt` (a solo run: one more than its `attempts.tsv` rows). Children take their
484
+ parent's; a shadow's is its dir's attempt; anything else is `null`.
485
+ - `model`: from the `<role>.resolved` copy (a running execution without one: the owner dir's
486
+ live `<role>.resolved`); `spec` is `alias[:effort]`, `effort` is `null` for
487
+ `<harness default>`. `null` for the pipeline roles and when there is no alias.
488
+ - `tokens` is `null` when all three counts are; `timed_out` is whether `<role>.timedout` exists.
489
+ - `verdict` and `findings` (FindingsSummary): `qa` only, from its own `qa.last` copy once done.
490
+ - Executions are ordered by `started` (unknown last), then `id`.
491
+
492
+ ## FindingsSummary and Finding
493
+
494
+ ```json
495
+ {"P0": 0, "P1": 1, "P2": 2, "P3": 0, "unlabelled": 0, "plausible": 1, "confirmed": 3, "worst": "P1"}
496
+ ```
497
+
498
+ `P0`..`P3` count CONFIRMED findings by severity, `confirmed` all CONFIRMED ones, `unlabelled`
499
+ the CONFIRMED ones without a severity plus the UNSTATED ones (as the gate's `n_unlabelled`),
500
+ `plausible` the PLAUSIBLE ones; `worst` is the most severe CONFIRMED label or `null`. A
501
+ FindingsSummary is `null` when there is no text or no contract module.
502
+
503
+ ```json
504
+ {"severity": "P1", "status": "CONFIRMED", "location": "src/x.py:12", "line": 7,
505
+ "summary": "- [P1][CONFIRMED] src/x.py:12 — first line, at most 240 characters",
506
+ "text": "the whole finding block"}
507
+ ```
508
+
509
+ `status` is `CONFIRMED`, `PLAUSIBLE` or `UNSTATED`; `severity` may be `null`; `location` is the
510
+ first match of the contract's `_LOCATION` in the first line, or `null`; `line` is the
511
+ block's 1-based line in the reply. Lists of Findings keep the first 200.
512
+
513
+ ## FinalReviewSummary
514
+
515
+ ```json
516
+ {"name": "final-review-1760622000-4242", "source": "review", "at": 1760622000.0, "published": true,
517
+ "verdict": "FAIL", "state": "done", "spec": "opus", "threshold": "P2", "base": "1a2b...", "tip": "3c4d...",
518
+ "gate": {...}, "findings": FindingsSummary, "execution": "qa-...",
519
+ "started": 1760622010.0, "ended": 1760622400.0, "duration_s": 390.0}
520
+ ```
521
+
522
+ - `name` is the dir name; an integration's review lives in `integrate-<e>-<p>/final-review/`
523
+ and is named `integrate-<e>-<p>` with `source` `integrate`. `at` is `<e>`.
524
+ - `published`: the plan's `final-review` link resolves to this dir (a link leaving the plan
525
+ publishes nothing).
526
+ - `state`: `done` with a verdict, `infra` with `qa.infra`, `running` when it is the newest
527
+ review and has `qa.input`, else `orphaned`.
528
+ - `spec`, `threshold`, `base`, `tip` come from `review.meta` (`threshold=none` is `null`).
529
+ - `execution` is its newest `attempts/qa-*`; `started`, `ended`, `duration_s` are that
530
+ execution's.
531
+
532
+ ## Lazy details
533
+
534
+ TaskDetail:
535
+
536
+ ```json
537
+ {"run": "3f9a0c1d2e4b", "id": "reader",
538
+ "texts": {"prompt": "...", "approach": null, "retry_findings": "...", "accepted_by": null,
539
+ "known_issues": null, "dwarf_input": "...", "qa_input": "..."},
540
+ "gate": {...},
541
+ "reviews": [{"kind": "primary", "execution": "qa-...", "attempt": 1, "spec": "opus",
542
+ "verdict": "FAIL", "state": "done", "findings": [Finding, ...]}],
543
+ "truncated": [], "warnings": []}
544
+ ```
545
+
546
+ `texts` read `prompt.md`, `approach.md`, `retry_findings.md`, `accepted.by`,
547
+ `known_issues.md`, `dwarf.input` and `qa.input` (a solo run: `prompt.md`, `known_issues.md`,
548
+ `dwarf.input`, `qa.input`; the others are `null`). `reviews` lists each non-shadow `qa`
549
+ execution with the findings of its own `qa.last` copy (`null` until it is done); then the
550
+ task's current `qa.last` as a `primary` review with `execution` `null`, only when no
551
+ execution's copy is identical to it; then one `shadow` review per shadow dir.
552
+
553
+ ExecutionDetail: the Execution, plus `resolved` (every `key=value` of `<role>.resolved`, or
554
+ `null`), `dataset` (`{trigger, parent, attempt_start, resume}` or `null`), `prompt` (the
555
+ `<role>.prompt` copy; while running without one, the owner dir's `<role>.delivered`, then
556
+ `.prompt`, then `.input`), `last` (the `<role>.last` copy), `findings` (a list of Findings for
557
+ a finished `qa`, else `null`, in place of the summary), `owner`
558
+ (`{"kind": "task" | "shadow" | "final_review" | "run", "id": ...}`: the task id (`solo` for a
559
+ solo run), `<task>/<attempt>-<k>`, the review name, or `null`), `truncated` and `warnings`.
560
+
561
+ FinalReviewDetail: the FinalReviewSummary, plus `meta` (every `review.meta` key),
562
+ `tasks_summary`, `qa_input`, `qa_last`, `known_issues` (texts or `null`), `findings` (a list
563
+ of Findings, in place of the summary), `truncated` and `warnings`.
564
+
565
+ ## DiffDetail (`forge_ui/diffs.py`)
566
+
567
+ ```json
568
+ {"run": "3f9a0c1d2e4b",
569
+ "source": {"kind": "task", "id": "reader", "which": "current", "execution": null, "attempt": 2,
570
+ "file": "tasks/reader/changes.diff"},
571
+ "sources": [{"id": "current", "label": "changes.diff", "bytes": 1234, "url": "/api/runs/…/tasks/reader/diff"},
572
+ {"id": "prev", "label": "changes.prev.diff", "bytes": 980, "url": "…/tasks/reader/diff/prev"},
573
+ {"id": "qa-…", "label": "reviewed diff, attempt 1", "attempt": 1, "verdict": "FAIL",
574
+ "kind": "primary", "bytes": 980, "url": "/api/runs/…/executions/qa-…/diff"}],
575
+ "available": true, "reason": null, "bytes": 1234, "truncated": false,
576
+ "files": [{"old": "ui/reader.py", "new": "ui/reader.py", "status": "modified", "binary": false,
577
+ "additions": 5, "deletions": 1, "truncated": false,
578
+ "hunks": [{"old_start": 38, "old_lines": 7, "new_start": 38, "new_lines": 10,
579
+ "section": "def _tasks(reader):", "lines": [" ctx", "-old", "+new"]}]}],
580
+ "findings": [{"review": {"kind": "primary", "execution": "qa-…", "attempt": 2, "spec": "opus"},
581
+ "severity": "P1", "status": "CONFIRMED", "location": "ui/reader.py:42",
582
+ "summary": "…", "text": "…",
583
+ "pin": {"file": 0, "path": "ui/reader.py", "line": 42, "side": "new", "hunk": 0, "index": 5},
584
+ "off_diff": null}],
585
+ "warnings": [], "truncated_fields": []}
586
+ ```
587
+
588
+ | Field | Meaning |
589
+ |---|---|
590
+ | `source.kind` | `task`, `execution`, `final_review` or `fractal`; `id` the task id, execution name, review name or node |
591
+ | `source.which` | `current` `prev` `committed` `existing` (task), `reviewed` (execution, final review), `candidate` (fractal) |
592
+ | `source.execution`, `attempt` | the qa execution whose prompt held the diff (a final review: its newest), its attempt; a task's `current` and `committed` carry the task's attempt, `prev` and `existing` `null` |
593
+ | `source.file` | the file read, relative to the run dir (fractal: to the Fractal state's `runs/<run>/tasks/`); `null` when none was |
594
+ | `sources` | what else can be shown. Task (and its executions): every diff file present (`id` = `which`, `label` = file name, `bytes` its size), then every primary and shadow qa execution whose prompt has a fence (`id` the execution, `label` `reviewed diff, attempt N` / `shadow review, attempt N`, `attempt`, `verdict`, `kind` `primary`/`shadow`, `bytes` of the fenced diff). Final review: itself plus the newest 20 reviews that kept a `combined.diff` (`id` the name, `kind` `final`, `verdict`, `bytes` or `null`). Fractal: `[{"id": "candidate", "label": "candidate.diff", "bytes"}]`. Every entry but fractal's has `url`, the endpoint that shows it |
595
+ | `available`, `reason` | whether a diff was read; else why: `no changes.diff` (any missing file's name), `unreadable (see warnings)`, `no diff in the review prompt`, `no review prompt`, `no review of this attempt` (a dwarf), `not inlined (larger than 400 KB)` (final reviews), `no review prompt yet`, `no candidate diff yet` |
596
+ | `bytes` | the file's size, or the fenced diff's (UTF-8); `null` when unavailable |
597
+ | `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
+ | `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
+ | `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` |
601
+ | `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
+ | `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
+
604
+ Which diff: a task's `changes.diff` etc. from its dir (a solo run's from the run dir). An
605
+ execution's is the last fence of its `qa.prompt` copy; a running one's, of its owner's
606
+ `qa.delivered`, `.prompt`, then `.input`; a shadow's falls back to its dir's `qa.input`; a
607
+ dwarf's is its attempt's primary review's (same parent, else same attempt). A final review's
608
+ is `combined.diff`, else the fence of its newest execution's `qa.prompt`, then of its
609
+ `qa.input`.
610
+
611
+ Which findings: a task diff gets every review (primary, the task's own `qa.last` when no copy
612
+ matches it, shadows) of the task's latest attempt; an execution diff that review's (a
613
+ primary's also brings the shadows of its attempt); a final review's its `qa.last`'s; a fractal
614
+ node's none.
615
+
616
+ Parsing: `git diff --binary` output and plain unified diffs; renames, copies, mode changes,
617
+ `/dev/null`, C-quoted paths (`"a/caf\303\251"`), CRLF, the mnemonic and no-prefix forms.
618
+ A hunk's lines are counted, so a deleted `-- x` (the line `--- x`) stays content. At most 300
619
+ files, 20,000 hunk lines in all and 2,000 characters a line (then `…`).
620
+
621
+ Pinning: the path is the Finding's `location` (`a.py:10-12` arrives as `a.py:10`); without one,
622
+ the first `path:N` of its first line that names a file of this diff (`Makefile:3`). `line N`
623
+ alone pins only in a one-file diff. Paths are matched after `\` → `/` and dropping `./`, with
624
+ and without an `a/`/`b/` prefix: exactly first, then as a `/`-suffix either way; two files
625
+ matching is `ambiguous path`. The line is looked up on the new side (old for a deleted file).
626
+
627
+ Reads stay inside the run dir after symlinks are resolved, open regular files only (a FIFO is
628
+ never opened) and read at most 8 MiB; parses are kept by `(dev, inode, size, mtime_ns)` as
629
+ other parses are.
630
+
631
+ ## Files
632
+
633
+ `GET /api/runs/<key>/files`: who planned, changed, drifted into or conflicted on each file.
634
+
635
+ ```json
636
+ {"run": "3f9a0c1d2e4b",
637
+ "files": [{"path": "ui/reader.py", "dir": "ui", "planned_by": ["reader"], "touched_by": ["reader"],
638
+ "drift_by": [], "conflict_by": []}],
639
+ "planned_dirs": [{"path": "ui/", "planned_by": ["page"]}],
640
+ "tasks": [{"id": "reader", "wave": 1, "status": "MERGED", "planned": ["ui/reader.py"], "planned_all": false,
641
+ "attempts": [{"attempt": 1, "execution": "qa-…", "files": ["ui/reader.py"]}],
642
+ "current": ["ui/reader.py"], "drift": [], "conflicts": []}],
643
+ "overlaps": [{"a": "reader", "b": "docs", "planned": true, "touched": ["ui/reader.py"], "same_wave": false}],
644
+ "deferrals": ["…"], "truncated": false, "warnings": []}
645
+ ```
646
+
647
+ - `tasks[]`: `planned` is the `files` column, normalised (`ui/` → `ui`); `planned_all` when it
648
+ has `.`. `attempts`: each primary qa execution whose prompt has a fence, with the paths that
649
+ diff touched (from its headers: the new path, a deleted file's old one). `current`: those of
650
+ `changes.diff`. `drift`, `conflicts`: the lines of `drift.txt`, `conflict.files`. A solo
651
+ run has one task `solo` (wave 1, nothing planned) with also `existing`, the paths of
652
+ `existing.diff`.
653
+ - `files[]`, by path: every path touched (by `current` or any attempt), drifted, conflicted, or
654
+ planned as a file. `dir` is its dirname (`""` at the root). `planned_by`: tasks with a planned
655
+ entry equal to it or a dir above it (a `.` task is left out: see `planned_all`);
656
+ `touched_by`, `drift_by`, `conflict_by` in `tasks.tsv` order.
657
+ - `planned_dirs`: planned entries that end in `/` or hold a touched file, with a trailing `/`.
658
+ - `overlaps`: each pair of tasks (in `tasks.tsv` order) whose planned entries overlap by
659
+ forge-schedule's rule (equal, one a dir above the other, or either `.`) or that touched a
660
+ file in common (`touched`, sorted); `same_wave` from `waves.tsv`.
661
+ - At most 2,000 files, 300 tasks and 5,000 overlaps; past them `truncated` is true.
662
+
663
+ ## Models
664
+
665
+ `GET /api/models`: facts about every run, newest `updated` first, for the page to aggregate.
666
+
667
+ ```json
668
+ {"generated": 1760621412.5, "runs": 12, "truncated": false,
669
+ "tasks": [{"run": "3f9a0c1d2e4b", "task": "reader", "kind": "plan", "repo": "/x/y", "difficulty": "high",
670
+ "created": 1760619000.0, "explored": true, "propensity": 0.125, "final_status": "MERGED",
671
+ "accepted": true,
672
+ "attempts": [{"n": 1, "alias": "luna", "spec": "luna", "source": "execution", "outcome": "fail",
673
+ "reason": null, "gate": false, "model_s": 297.0, "duration_s": 300.0,
674
+ "tokens": {"input": 120000, "cached": 90000, "output": 8000}, "timed_out": false}],
675
+ "escalations": [{"n": 1, "from": "luna", "to": "sol:xhigh"}]}],
676
+ "reviews": [{"run": "3f9a0c1d2e4b", "task": "reader", "n": 1, "kind": "primary", "alias": "opus", "spec": "opus",
677
+ "verdict": "FAIL", "findings": FindingsSummary, "duration_s": 270.0, "tokens": {...},
678
+ "agreement": null, "primary_alias": null}],
679
+ "warnings": ["3f9a0c1d2e4b: tasks/x/attempts.tsv line 2: invalid attempt"]}
680
+ ```
681
+
682
+ - `tasks[]`: one per task (`solo` for a solo run). `explored` as Task's; `propensity` that of
683
+ the newest `route.jsonl` decision, else `null`; `final_status` the task (or solo) status;
684
+ `accepted` whether `accepted.by` is non-empty (`false` for a solo run). `escalations` is
685
+ `escalations.tsv` as it is (`n` its attempt column, `from`/`to` specs).
686
+ - `attempts[]`, from `attempts.tsv` (`epoch n status infra spec`): a row with an infra class
687
+ or status `INFRA` was refunded and is dropped; the last row of a number wins (a QA-only
688
+ resume reuses it). `outcome`: `PASS`/`MERGED` `pass`, `FAIL` `fail`. `TIMEOUT`/`ERROR` with a
689
+ primary qa execution of that attempt is `censored` with `reason` `reviewer_timeout`/
690
+ `reviewer_error`; else `TIMEOUT` is a `fail` when the attempt's last dwarf execution exited 7
691
+ or timed out, otherwise `censored`, `dwarf_*` (it exited non-zero) or `forge_*`. Any other
692
+ status is `censored` with the status lowercased (`nochanges`, `interrupted`, ...), as
693
+ `scripts/forge_dataset/export.py` labels them.
694
+ - `alias`/`spec`/`source`: the last dwarf execution's model (`execution`), else the fifth
695
+ `attempts.tsv` column (`attempts.tsv`), else for attempt 1 only the `tasks.tsv` dwarf (a solo
696
+ run's `dwarf.spec`/`dwarf.resolved`) (`plan`), else all `null`.
697
+ - `gate`: the attempt passed although its (last) primary review said FAIL. `model_s`,
698
+ `duration_s`, `tokens`, `timed_out`: that dwarf execution's `phases.model`, `duration_s`,
699
+ `tokens`, `timed_out`, else `null`/`false`.
700
+ - `reviews[]`: every finished primary qa execution (`n` its attempt); every shadow whose
701
+ `shadow.status` is `done` (`alias`/`spec` its execution's model, else `shadow.json`'s spec;
702
+ `agreement` and `primary_alias` against the primary review of its attempt); every finished
703
+ final review (`kind` `final`, `task` and `n` `null`, `verdict` `PASS`/`FAIL` else `null`,
704
+ timing from its newest execution).
705
+ - Per run the facts are kept, keyed by its realpath, until its RunSummary's `updated`,
706
+ `task_count`, `counts` or `final_review` changes (4,096 runs); a run with a `RUNNING` task is
707
+ read again after 120 s. One computation runs at a time. Past 50,000 task rows the next run
708
+ is left out and `truncated` is true. `warnings` (first 500) are prefixed by the run key.
709
+
710
+ ## Fractal (`forge_ui/fractal.py`)
711
+
712
+ A run that chose Fractal (`fractal-selection.json`) has its tasks executed by
713
+ `scripts/forge_fractal` as trees of child tasks, in a managed run under the state root:
714
+ `realpath($XDG_STATE_HOME or ~/.local/state)/forge/fractal` (unlike forge, an empty
715
+ `XDG_STATE_HOME` counts as unset). forge-ui never imports that package.
716
+
717
+ | Path under `runs/<run>/` | Read |
718
+ |---|---|
719
+ | `run.json` (≤ 1 MiB) | its `runner` must be the run dir's realpath |
720
+ | `tasks/task-<sha256(T)[:16]>/` | the Fractal task of forge task `T` = realpath of `tasks/<id>` (plan) or of the run dir (solo) |
721
+ | `…/request.json` (≤ 2 MiB) | its `output` must be `T` |
722
+ | `…/state.json`, `outcome.json` (≤ 256 KiB) | `state`, `outcome` |
723
+ | `…/events.jsonl` | its last 64 KiB |
724
+ | `…/nodes/<n>/node.json` (≤ 4 MiB) | one node; a bigger one is skipped with a warning |
725
+ | `…/nodes/<n>/candidate.diff` | the node's imported change |
726
+
727
+ Everything is opened through directory fds, one component at a time with `O_NOFOLLOW`
728
+ (files also `O_NONBLOCK`, regular only, bounded): a symlink anywhere is refused with a
729
+ warning. `nodes/` is the only dir listed, and only names matching
730
+ `[A-Za-z0-9][A-Za-z0-9_-]{0,100}`. `control/`, `nodes/*/product/`, `planning/`, `steps/` and
731
+ the ledger are never opened, nor any absolute path a node stores. Parses are kept by
732
+ signature and path.
733
+
734
+ RunDetail `fractal`: `{"enabled", "run", "available", "reason"}`. `enabled` is true only for
735
+ `"enabled": true`; `run` must match `run-[0-9a-f]{20}`. Reasons: `Fractal is off for this run`,
736
+ `invalid fractal-selection.json` (not a ≤ 64 KiB JSON object), `invalid run id`,
737
+ `managed run not found`, `invalid run.json`, `runner mismatch`, `not supported on this
738
+ platform`.
739
+
740
+ TaskFractal:
741
+
742
+ ```json
743
+ {"run": "3f9a0c1d2e4b", "task": "reader", "fractal_run": "run-…", "fractal_task": "task-…",
744
+ "available": true, "reason": null,
745
+ "state": {"status": "active", "acceptance": "pending", "elapsed": 120.5, "started_at": 1760600020.0,
746
+ "ended_at": null, "exit_code": null, "error": null, "qa_status": null},
747
+ "outcome": {"acceptance": "PASS", "verification": "passed", "task_status": "PASS"},
748
+ "nodes": [{"id": "implementation", "parent": null, "depth": 0, "goal": "…", "difficulty": "medium",
749
+ "paths": ["ui"], "deps": [], "model": "sol:xhigh", "resolved_model": "…", "status": "waiting",
750
+ "ui_status": "RUNNING", "phase": "implementation", "iteration": 1, "started_at": 1760600022.0,
751
+ "ended_at": null, "cost": 0.42, "unknown_cost_steps": 0,
752
+ "tokens": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 2},
753
+ "decomposition": {"phase": "accepted", "decision": "split", "reason": "…", "children": ["a", "b"]},
754
+ "error": null, "has_diff": false, "reserved": false}],
755
+ "events": [{"at": 1760600021.0, "kind": "node_created", "node": "implementation"}],
756
+ "warnings": [], "truncated": []}
757
+ ```
758
+
759
+ - `available: false` (`state`, `outcome` `null`, `nodes`, `events` `[]`) with the RunDetail
760
+ reasons, or `Fractal is not selected for this run`, `no Fractal task for this task yet`,
761
+ `request does not match this task`, `invalid request.json`.
762
+ - `nodes`: tree order (a parent, then its children by id), at most 500 node dirs (else
763
+ `truncated` has `nodes`). Children a decomposition admitted (`decomposition.admission` and
764
+ `decomposition_history[].admission`) that have no dir yet are listed with `status`
765
+ `reserved` and `reserved: true`. `depth` counts parents. `goal` and `error` keep 240
766
+ characters, a decision's `reason` 2,000. Values of the wrong type are `null` (lists `[]`).
767
+ `has_diff`: `candidate.diff` is a regular file.
768
+ - `ui_status`: `pending` `PENDING`, `active`/`waiting` `RUNNING`, `paused` `BLOCKED`,
769
+ `completed` `MERGED`, `timeout` `TIMEOUT`, `interrupted`/`stopped` `INTERRUPTED`, `reserved`
770
+ `PENDING`, any other status `FAIL`, none `UNKNOWN`.
771
+ - `events`: the newest 50 JSON lines of `events.jsonl` with a `kind`, oldest first: `at`,
772
+ `kind`, `node`, and those of `phase decision status acceptance exit_code iteration cost
773
+ difficulty model reason` the line has. Other lines are counted in a warning.
774
+
775
+ FractalNodeDetail (`…/fractal/nodes/<node>`): the node as listed, plus `goal`, `error` (20,000
776
+ characters), `last` and `planning_last` (their last 20,000, behind `…`), `planning_tokens`,
777
+ `decomposition` in full (`phase decision reason attempts error`, and `admission`: `name goal
778
+ difficulty paths deps model` each), `decomposition_history` (its newest 20, each like
779
+ `decomposition`, `reason` and `error` 2,000), and `run`, `task`, `fractal_run`,
780
+ `fractal_task`, `warnings`, `truncated` (the texts cut). A reserved node has these `null` or
781
+ `[]`, with its admission's `goal`.
782
+
783
+ ## Export (`forge_ui/export.py`)
784
+
785
+ `GET /api/runs/<key>/export` answers one self-contained HTML file named
786
+ `forge-run-<run_id>.html` (the run id with anything but `[A-Za-z0-9._-]` made `-`).
787
+
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.
794
+ - `api` maps each path the page requests to that endpoint's response as the server would answer
795
+ 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).
799
+ - `texts=0` (the default) nulls these keys wherever they hold a string, list or object: `prompt
800
+ 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`.
803
+ - `paths=0` (the default) makes every string that is a whole absolute path (but `/api/…` and
804
+ `url`s) its last component, and replaces the run dir, the repo and the home dir inside other
805
+ strings with their last component (`~` for home).
806
+ - At most 1 MB of JSON per diff and 25 MB in all; what did not fit is listed in `meta.omitted`
807
+ (`path`, `bytes`, `why`). `meta`: `format` `forge-ui-export`, `version` 1, `app`, `key`,
808
+ `generated`, `texts`, `paths`, `omitted`.
809
+ - The page's `<meta http-equiv="Content-Security-Policy">`: `default-src 'none'; script-src
810
+ <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';
812
+ form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
813
+ JSON escapes `<`, `>` and `&`.
814
+ - In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
815
+ exported run (any other route goes to it) and a banner with the export's date and what it left
816
+ out; the cloud, the leaderboard and notifications are not there.
817
+
818
+ ## Status vocabulary for the page and the graph
819
+
820
+ Both the page and the graph mark a task by its status with the CSS class
821
+ `st-<status in lowercase>`, e.g. `st-merged`, `st-running`, `st-pending`. `style.css`
822
+ (owned by the page) defines, for every status above plus `pending`, a custom property
823
+ `--st` holding that status's color, e.g. `.st-merged { --st: #2f9e44; }`. No script
824
+ hard-codes status colors: `graph.js` and `gantt.js` give their SVG elements classes that
825
+ `style.css` fills with `var(--st, #888)` (the page CSP refuses style attributes, so a script
826
+ never writes one), and the 3D stage reads `--st` from a hidden `.st-<status>` probe. The
827
+ stage's own background and accent are `--stage-bg` and `--stage-accent` on `.stage-slot`.
828
+
829
+ Suggested meaning: merged and pass green, running blue, pending grey, fail, error and conflict
830
+ red, infra and timeout orange, blocked, interrupted, invalidated, unknown and nochanges muted
831
+ purple.
832
+
833
+ ## The graph (`forge_ui/static/graph.js`)
834
+
835
+ A plain script (no modules) that defines one global:
836
+
837
+ ```js
838
+ window.ForgeGraph = {
839
+ render(container, run, options) // run: a RunDetail; options: {selected, onSelect}
840
+ };
841
+ ```
842
+
843
+ `render` replaces the contents of `container` with one SVG showing every task of
844
+ `run.tasks` as a node and every dependency as an edge from the dep to the dependent.
845
+ Columns are waves (`task.wave`); a task with no wave is placed one column right of its
846
+ deepest dependency (column 1 when it has none). Each node shows the task id and its
847
+ `dwarf` spec; a node with `explored: true` is visibly marked (e.g. a dashed outline and a
848
+ `*` after the spec). `options.selected` is a task id drawn highlighted.
849
+ Clicking a node calls `options.onSelect(taskId)`. It must work with 0 tasks (renders an
850
+ empty-state message), with deps that name unknown tasks (edge skipped), and with cycles
851
+ (no infinite loop).
852
+
853
+ ## The page's other scripts
854
+
855
+ Plain scripts unless noted; each defines one global and none parses API text as HTML.
856
+
857
+ | File | Global | What it is |
858
+ |---|---|---|
859
+ | `runmodel.js` | `ForgeRunModel` | pure data, no DOM: the plate layout (archivision's `plate()`), the atlas, plan and execution models, forge-schedule's rules projected forward, the critical path, concurrency, the state of every node at a time `t` (replay), filters. Also a CommonJS module, for `node --test` |
860
+ | `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
+ | `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
+ | `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}` |
863
+ | `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
+ | `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
+ | `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 |
866
+ | `modelstats.js` | `ForgeModelStats` | pure: the leaderboard's aggregation of `/api/models` (`leaderboard`, `wilson`, `options`). Also a CommonJS module |
867
+ | `leaderboard.js` | `ForgeLeaderboard` | `render(host, data, {runs, prefs, savePrefs, modelHref, now})`: the `#/models` page |
868
+ | `search.js` | `ForgeSearch` | `init({runs, commands, go, cloudFilter})`: the quick jump (Ctrl/⌘ K, `/`), ranked by `ForgeRunModel.searchRank` |
869
+ | `notify.js` | `ForgeNotify` | `init({go})`, `observe(runs)` on each poll of the run list (`ForgeRunModel.statusEvents`), `open()`, `background()`: the bell, its log, the title count, the favicon dot and browser notifications |
870
+ | `map3d.js` | `ForgeMap3D` | `load()` imports `scene3d.js` (and so three.js) on first use, with a 12 s timeout, or in an export the bundled stage from a `blob:` URL; `state` is `idle`, `loading`, `ready` or `unavailable`, `message()` says why |
871
+ | `scene3d.js` | (ES module) | `createStage(host, {onSelect, onOpenRun, onBack, onMode, cloud})`: the atlas, plan and execution scenes, live status updates without rebuilding, overlays, replay, Fractal trees over opened tasks (`setFractal`), and `showCloud(runs, opts)` |
872
+ | `cloud3d.js` | (ES module) | `createCloud(engine, host, callbacks)`: the run cloud on the stage's engine (blocks or orbs, labels, frames, drag and drop kept per layout in this browser) |
873
+ | `iso.js` | (ES module) | the iso-glow engine ported from archivision: renderer, materials, blocks, labels, cables, camera (plate and free profiles), picking and grabbing, shared solids, sprites, drawn on demand |
874
+
875
+ Routes (the URL hash): `#/` the map of every run; `#/cloud`, `#/cloud/runs/<key>` (a run
876
+ selected), `#/cloud/projects/<id>` (a project in focus), `#/cloud/models/<alias>` (the cloud
877
+ filtered to that model); `#/models` the leaderboard; `#/runs/<key>`; `…/tasks/<id>`;
878
+ `…/tasks/<id>/executions/<name>`; `…/executions/<name>` (an execution that belongs to no
879
+ task); `…/final-review[/<name>]`; `…/verification`; `…/integration`; `…/tasks/<id>/fractal/<node>`;
880
+ and the diff viewer: `…/tasks/<id>/diff[/<source>][/file/<path>]` (`source` is `current`, `prev`,
881
+ `committed`, `existing` or an execution), `…/final-review/<name>/diff[/file/<path>]`,
882
+ `…/tasks/<id>/fractal/<node>/diff[/file/<path>]` (`path` percent-encoded whole).
883
+
884
+ Kept in the browser (localStorage, every access guarded): `forge-ui-view`, `-mode`, `-overlay`,
885
+ `-speed`, `-map-filter`; `forge-ui-cloud` (filter, layout, shape, panel) and
886
+ `forge-ui-cloud-layout-v1` (dragged positions per layout, at most 5,000 per layout);
887
+ `forge-ui-models` (leaderboard filters); `forge-ui-recent` (quick-jump picks); `forge-ui-notify`,
888
+ `-notify-log` (the last 100) and `-notify-seen` (ten minutes, to notify once across tabs).