@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
@@ -0,0 +1,996 @@
1
+ /* forge-ui stage: forge runs drawn with iso.js.
2
+ - atlas: every run on one plate, one district per repo, one block per run with a lamp per task;
3
+ - plan: one run's waves as districts, a block per task, and the finish line (integration, verification,
4
+ final review); opening a task shows its attempts, each with its dwarf, its review and any shadow reviews;
5
+ - exec: the same run on wall-clock time, one block per attempt in the lane it really ran in;
6
+ - cloud: every run floating in 3D, grouped by project (cloud3d.js), on the same engine and WebGL context.
7
+ The scene logic (blocks, roofs, lamps, opening, cables, beads) is archivision's assets/figure.js (MIT,
8
+ © 2026 Giamme; drawing style iso-glow, MIT © 2026 April Zhu and Tolga Cohce: see the notice in iso.js).
9
+ All text from the API is drawn on canvases or set with textContent, never parsed as HTML. */
10
+ import { createEngine } from './iso.js';
11
+ import { createCloud } from './cloud3d.js';
12
+
13
+ const RM = window.ForgeRunModel;
14
+ const list = v => Array.isArray(v) ? v : [];
15
+ const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
16
+ const clamp = v => Math.max(0, Math.min(1, v));
17
+ const segT = (v, a, b) => clamp((v - a) / (b - a));
18
+ const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
19
+ const back = t => t <= 0 ? 0 : t >= 1 ? 1 : 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
20
+ const SEV_STATUS = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
21
+ const MODEL_PALETTE = ['#4e9af1', '#f08c3a', '#6cc070', '#c46fd8', '#e0c341', '#4fc2c0', '#e06a8c', '#9a8cff', '#a3b84a', '#d98f6b'];
22
+ const KIND_H = { dwarf: 24, qa: 13, shadow: 12 };
23
+
24
+ export async function createStage(host, cb = {}) {
25
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
26
+ const E = await createEngine(host, { reduceMotion: reduce });
27
+ const { THREE, W, TS } = E;
28
+ const TMP = new THREE.Color(), TMP2 = new THREE.Color();
29
+ const tip = document.createElement('div');
30
+ tip.className = 'stage-tip'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); host.append(tip);
31
+
32
+ const st = { view: null, mode: 'plan', run: null, runs: [], filter: {}, atlasOpts: {}, sel: null, hover: null, replay: null,
33
+ overlay: { height: 'none', color: 'status', critical: false, arcs: true, ghosts: true },
34
+ structure: '', execSig: '', atlasSig: '', rebuilds: 0, fade: null, plan: null, exec: null, atlas: null, crit: null, now: Date.now() / 1000 };
35
+ let world = null; // the group of the current view
36
+ let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map();
37
+ const nowSec = () => st.run ? RM.liveNow(st.run, Date.now() / 1000) : Date.now() / 1000;
38
+
39
+ /* ---------- elements: a block, a part or a lane item, each with its own materials ---------- */
40
+ function newEl(id, kind, g, n, data) {
41
+ const el = { id, kind, g, n, data, status: 'PENDING', col: E.statusColor('pending'), root: new THREE.Group(), cover: new THREE.Group(),
42
+ M: E.mats(), cur: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 }, tgt: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 },
43
+ texts: [], sig: '', pulse: false, offset: new THREE.Vector3(), fadeK: 1 };
44
+ el.root.add(el.cover); g.add(el.root); EL.set(id, el);
45
+ return el;
46
+ }
47
+ function body(el, { r = 9, z = 0, ud = {} } = {}) {
48
+ const { x, y, w, d, h } = el.n;
49
+ const m = E.solid(x, y, z, w, d, h, r, el.M, el.cover, { el: el.id, cover: true, ...ud }); PICK.push(m); el.body = m;
50
+ el.tintMat = new THREE.MeshBasicMaterial({ color: el.col.clone(), toneMapped: false, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -4 });
51
+ E.roofPlate(x + 1, y + 1, z + h + .08, w - 2, d - 2, r - 1, el.tintMat, el.cover);
52
+ el.haloMat = E.softMat(0xffffff, true);
53
+ const hm = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry((w + 80) * TS, (d + 80) * TS), el.haloMat));
54
+ hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, z + .5)); el.root.add(hm);
55
+ return m;
56
+ }
57
+ // a row of status lamps on a roof: one geometry, a colour per lamp
58
+ function leds(el, n, x0, y0, z, { step = 10, s = 3, perRow = 20 } = {}) {
59
+ if (!n) return;
60
+ const pos = [], col = [];
61
+ for (let i = 0; i < n; i++) {
62
+ const lx = x0 + (i % perRow) * step, ly = y0 - Math.floor(i / perRow) * step;
63
+ for (const [a, b] of [[-s, -s], [-s, s], [s, s], [-s, -s], [s, s], [s, -s]]) { pos.push(...W(lx + a, ly + b, z).toArray()); col.push(.5, .5, .5); }
64
+ }
65
+ const geo = new THREE.BufferGeometry();
66
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
67
+ geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
68
+ const mat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
69
+ el.cover.add(E.noRay(new THREE.Mesh(geo, mat))); el.M.labs.push(mat);
70
+ el.leds = { geo, n };
71
+ }
72
+ function setLeds(el, statuses) {
73
+ if (!el.leds) return;
74
+ const a = el.leds.geo.attributes.color;
75
+ for (let i = 0; i < el.leds.n; i++) {
76
+ const s = statuses[i];
77
+ const c = s ? E.statusColor(s) : E.DETAIL;
78
+ for (let k = 0; k < 6; k++) a.setXYZ(i * 6 + k, c.r, c.g, c.b);
79
+ }
80
+ a.needsUpdate = true;
81
+ }
82
+ function setStatus(el, status, col) {
83
+ el.status = status || 'PENDING';
84
+ el.col = col || E.statusColor(el.status);
85
+ el.pulse = el.status === 'RUNNING';
86
+ }
87
+ const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN']);
88
+ function baseTargets(el) {
89
+ const T = el.tgt, s = el.status, light = E.theme === 'light';
90
+ T.lit = PLAIN.has(s) ? 0 : .32;
91
+ T.tint = PLAIN.has(s) ? 0 : light ? .09 : .1;
92
+ T.vis = 1; T.op = 1; T.opB = 1;
93
+ if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
94
+ // parts are small: their status has to read from the colour of the whole piece
95
+ if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .28 : .32; T.lit = .55; }
96
+ }
97
+ // a label plane on the roof or the front, redrawn only when what it shows changes
98
+ function label(el, w, h, P, draw, where, at) {
99
+ const t = E.tplane(w, h, P, (x, s, C) => draw(x, s, C, el), where === 'root' ? el.root : el.cover, el.M);
100
+ if (at.front) E.frontAt(t, at.x, at.y, at.z); else E.roofAt(t, at.x, at.y, at.z);
101
+ el.texts.push(t); return t;
102
+ }
103
+ const redraw = el => { for (const t of el.texts) if (!t.gone) t.redraw(); };
104
+ function chip(x, s, C, X, Y, text, colHex, { fill = true, f = 6.5 } = {}) {
105
+ E.font(x, f, s, 600);
106
+ const w = text.length * f * E.CW + 6;
107
+ E.rr(x, s, X, Y, w, f * 1.6, f * .5);
108
+ if (fill) { x.fillStyle = colHex; x.globalAlpha = C.light ? .16 : .22; x.fill(); x.globalAlpha = 1; }
109
+ x.strokeStyle = colHex; x.lineWidth = Math.max(1, .35 * s); x.stroke();
110
+ x.fillStyle = colHex; x.textBaseline = 'middle'; x.fillText(text, (X + 3) * s, (Y + f * .82) * s); x.textBaseline = 'alphabetic';
111
+ return w;
112
+ }
113
+ function display(el, text, { w, x, y, z }) {
114
+ return label(el, w, 16, 4, (cx, s, C) => {
115
+ cx.fillStyle = C.glassOff; cx.strokeStyle = el.cur.sel > .5 ? C.acc : C.detail; cx.lineWidth = Math.max(1, .5 * s);
116
+ E.rr(cx, s, .5, .5, w - 1, 15, 3); cx.fill(); cx.stroke();
117
+ cx.fillStyle = E.statusHex(el.status); cx.beginPath(); cx.arc(7 * s, 8 * s, 2.6 * s, 0, 7); cx.fill();
118
+ const f = E.fit(text(el), w - 18, 12, 8.5, 1, 4.5);
119
+ if (f) { E.font(cx, f.f, s); cx.fillStyle = C.ink; cx.textBaseline = 'middle'; cx.fillText(f.L[0], 13 * s, 8.3 * s, (w - 18) * s); cx.textBaseline = 'alphabetic'; }
120
+ }, 'root', { front: true, x, y, z });
121
+ }
122
+ function halo(el) { el.haloMat.uniforms.color.value.copy(el.col); }
123
+
124
+ /* ---------- the plate and its districts ---------- */
125
+ function plateFor(g, px, py, districts, names) {
126
+ const plateG = new THREE.Group(); g.add(plateG);
127
+ const p = E.rbox(-34, -34, -16, px + 68, py + 68, 16, 26, E.bodyMat, plateG); p.userData = { plate: true }; PICK.push(p);
128
+ E.loopAt(-22, -22, px + 44, py + 44, 18, .06, E.lineMat, plateG, 12);
129
+ for (const dd of districts) {
130
+ E.inset(dd.x, dd.y, 0, dd.w, dd.d, 16, plateG);
131
+ const name = String(names(dd) || '').toUpperCase(); if (!name) continue;
132
+ const f = Math.max(9, Math.min(26, (dd.w - 60) / (name.length * (E.CW + .14))));
133
+ const tw = Math.min(dd.w - 30, name.length * f * (E.CW + .14) + 6);
134
+ const t = E.tplane(tw, f * 1.4, 4, (x, s, C) => {
135
+ E.font(x, f, s); x.letterSpacing = `${(f * .14 * s).toFixed(1)}px`; x.fillStyle = dd.warn ? E.statusHex('fail') : C.ink;
136
+ x.fillText(name, 0, f * 1.05 * s, tw * s); x.letterSpacing = '0px';
137
+ }, plateG);
138
+ E.roofAt(t, dd.x + 18, dd.y + 12, 0);
139
+ }
140
+ return plateG;
141
+ }
142
+
143
+ /* ---------- cables between blocks ---------- */
144
+ const flowMatFaint = () => E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 });
145
+ function cable(g, a, b, { kind = 'dep', planes = null } = {}) {
146
+ const A = [a.x + a.w / 2, a.y + a.d / 2, a.h + 2], B = [b.x + b.w / 2, b.y + b.d / 2, b.h + 2];
147
+ const [ea, eb] = E.ends(a, b, a.h + 2, b.h + 2);
148
+ const pts = E.arc(Math.hypot(A[0] - B[0], A[1] - B[1]) < 400 ? ea : A, Math.hypot(A[0] - B[0], A[1] - B[1]) < 400 ? eb : B, 14, .18, 120);
149
+ const mat = flowMatFaint(), line = E.polyLine(pts, mat, g);
150
+ const am = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
151
+ const cn = E.cone(pts, 5, am, g);
152
+ const f = { kind, line, mat, cone: cn, am, pts, cum: E.cumLen(pts), cur: 0, hot: 0, crit: 0, vis: 1, planes, from: null, to: null };
153
+ FLOWS.push(f); return f;
154
+ }
155
+
156
+ /* ---------- atlas: every run ---------- */
157
+ function runInfo(r) {
158
+ const c = r.counts || {}, total = r.task_count || Object.values(c).reduce((a, n) => a + n, 0);
159
+ const merged = (c.MERGED || 0) + (c.PASS || 0), running = c.RUNNING || 0;
160
+ const bad = Object.entries(c).filter(([s]) => ['FAIL', 'ERROR', 'CONFLICT', 'INFRA', 'TIMEOUT', 'INVALIDATED', 'INTERRUPTED', 'BLOCKED'].includes(s)).reduce((a, [, n]) => a + n, 0);
161
+ const parts = [r.kind === 'solo' ? (Object.keys(c)[0] || 'solo') : `${merged}/${total} done`];
162
+ if (running) parts.push(`${running} running`);
163
+ if (bad) parts.push(`${bad} failed`);
164
+ if (r.final_review) parts.push(`review ${r.final_review}`);
165
+ else if (r.verification_status) parts.push(`verify ${r.verification_status}`);
166
+ return parts.join(' · ');
167
+ }
168
+ const STATE_STATUS = { live: 'RUNNING', failed: 'FAIL', done: 'MERGED', pending: 'PENDING' };
169
+ function buildAtlas() {
170
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
171
+ st.atlas = A;
172
+ const g = new THREE.Group(); world.add(g);
173
+ const names = new Map(A.layers.map(l => [l.id, l.name]));
174
+ plateFor(g, A.px, A.py, A.districts, d => names.get(d.layer));
175
+ for (const m of A.modules) {
176
+ const p = A.place[m.id]; if (!p) continue;
177
+ const [x, y, w, d, h] = p;
178
+ const el = newEl('r:' + m.id, 'run', g, { x, y, w, d, h }, { run: m.run, module: m });
179
+ setStatus(el, STATE_STATUS[m.state]);
180
+ body(el);
181
+ label(el, w - 24, 48, 4, (cx, s, C, e) => {
182
+ const r = e.data.run, sel = e.cur.sel > .5;
183
+ const nm = E.fit(String(r.goal || 'Untitled run').split('\n')[0], w - 30, 32, 17, 2, 6);
184
+ if (nm) E.lines(cx, s, nm, 2, 2, sel ? C.accHi : C.inkHi, w - 30, 500);
185
+ E.font(cx, 7, s); cx.fillStyle = C.ink;
186
+ cx.fillText(E.ellipsis(`${RM.basename(r.repo)} · ${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, Math.floor((w - 30) / (7 * E.CW))), 2 * s, 44 * s);
187
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
188
+ leds(el, Math.min(60, m.tasks.length), x + 17, y + d - 14, h + .16, { step: 8, s: 2.4, perRow: Math.max(4, Math.floor((w - 30) / 8)) });
189
+ setLeds(el, m.tasks.map(t => t.status));
190
+ display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
191
+ halo(el);
192
+ }
193
+ E.frameBox([-34, -34, -16, A.px + 34, A.py + 34, 80], st.atlasFramed ? 700 : 1400, 30);
194
+ st.atlasFramed = true;
195
+ }
196
+ function updateAtlas() {
197
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
198
+ st.atlas = A;
199
+ for (const m of A.modules) {
200
+ const el = EL.get('r:' + m.id); if (!el) continue;
201
+ el.data.run = m.run; el.data.module = m;
202
+ setStatus(el, STATE_STATUS[m.state]); halo(el);
203
+ setLeds(el, m.tasks.map(t => t.status));
204
+ const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.verification_status]);
205
+ if (sig !== el.sig) { el.sig = sig; redraw(el); }
206
+ }
207
+ retarget();
208
+ }
209
+
210
+ /* ---------- plan: waves, tasks, the finish line ---------- */
211
+ function taskStatus(t) {
212
+ if (st.replay !== null && st.replayState) { const s = st.replayState.tasks[t.id]; return s ? s.status : 'PENDING'; }
213
+ return t.status || 'PENDING';
214
+ }
215
+ function taskLine(el) {
216
+ const t = el.data.task, s = el.status;
217
+ const bits = [s];
218
+ if (num(t.attempt) && t.attempt > 1) bits.push('attempt ' + t.attempt);
219
+ if (s === 'RUNNING') {
220
+ const live = list(t.executions).filter(e => e.state === 'running' && (e.role === 'dwarf' || e.role === 'qa')).pop();
221
+ if (live) bits.push(`${live.role === 'qa' ? 'QA' : 'dwarf'} · ${live.phase || ''} ${RM.fmtDur(nowSec() - (num(live.started) || nowSec()))}`.replace(/\s+/g, ' '));
222
+ } else {
223
+ const tt = RM.taskTimes(t, null, nowSec());
224
+ if (tt.busy) bits.push(RM.fmtDur(tt.busy));
225
+ }
226
+ return bits.join(' · ');
227
+ }
228
+ function buildTask(m, p, g) {
229
+ const [x, y, w, d, h] = p, t = m.task;
230
+ const el = newEl('t:' + t.id, 'task', g, { x, y, w, d, h }, { task: t });
231
+ setStatus(el, taskStatus(t));
232
+ body(el);
233
+ el.roof = label(el, w - 24, d - 26, 4, (cx, s, C, e) => {
234
+ const tk = e.data.task, sel = e.cur.sel > .5;
235
+ E.font(cx, 21, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(E.ellipsis(tk.id, Math.floor((w - 30) / (21 * E.CW))), 2 * s, 19 * s);
236
+ const tl = E.fit(tk.title || '', w - 30, 24, 10, 2, 5);
237
+ if (tl) E.lines(cx, s, tl, 2, 25, C.ink, w - 30, 400);
238
+ E.font(cx, 9, s, 500); cx.fillStyle = C.ink;
239
+ cx.fillText(E.ellipsis(`${tk.dwarf || '—'}${tk.explored ? ' *' : ''} → ${tk.qa || '—'}`, Math.floor((w - 30) / (9 * E.CW))), 2 * s, 57 * s);
240
+ let bx = 2;
241
+ const f = tk.findings;
242
+ if (f && (f.confirmed || f.plausible)) {
243
+ for (const sev of ['P0', 'P1', 'P2', 'P3']) if (f[sev]) bx += chip(cx, s, C, bx, 64, `${sev}×${f[sev]}`, E.statusHex(SEV_STATUS[sev])) + 3;
244
+ if (f.unlabelled) bx += chip(cx, s, C, bx, 64, `?×${f.unlabelled}`, E.statusHex('unknown')) + 3;
245
+ }
246
+ if (tk.gate && tk.gate.decision === 'accept') bx += chip(cx, s, C, bx, 64, 'gate ok', E.statusHex('pass')) + 3;
247
+ if (st.overlay.arcs && list(tk.shadow).some(x => x.agreement === 'disagree')) bx += chip(cx, s, C, bx, 64, '⚑ shadow', E.statusHex('conflict')) + 3;
248
+ if (tk.accepted_by) chip(cx, s, C, bx, 64, 'accepted', E.statusHex('nochanges'));
249
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
250
+ const tries = Math.max(1, list(t.attempts).length);
251
+ leds(el, Math.min(20, tries), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
252
+ el.ledCount = tries;
253
+ display(el, taskLine, { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
254
+ // the Clef ring: how likely the first try was to pass, in the colour of what happened
255
+ const p0 = t.clef && t.clef.first_pass && num(t.clef.first_pass.p);
256
+ if (p0 !== null) {
257
+ const cx0 = x + w - 22, cy0 = y + 22, rIn = 8, rOut = 11.5;
258
+ const bgM = new THREE.MeshBasicMaterial({ color: E.DETAIL.clone(), toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
259
+ const fgM = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -5, polygonOffsetUnits: -14 });
260
+ for (const [m, len] of [[bgM, Math.PI * 2], [fgM, Math.max(.05, Math.min(1, p0)) * Math.PI * 2]]) {
261
+ const ring = E.noRay(new THREE.Mesh(new THREE.RingGeometry(rIn * TS, rOut * TS, 40, 1, Math.PI / 2, len), m));
262
+ ring.rotation.x = -Math.PI / 2; ring.position.copy(W(cx0, cy0, h + .2)); el.cover.add(ring); el.M.labs.push(m);
263
+ }
264
+ el.clef = { fgM, p: p0 };
265
+ }
266
+ halo(el);
267
+ return el;
268
+ }
269
+ function finishInfo(id) {
270
+ const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
271
+ if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
272
+ if (id === 'f:verification') return { title: 'VERIFICATION', sub: F.verification.command || 'no command recorded', status: F.verification.status, line: F.verification.runs ? `${F.verification.runs} run${F.verification.runs > 1 ? 's' : ''}` : 'not run yet' };
273
+ const f = F.final.findings;
274
+ return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status: F.final.status, findings: f,
275
+ line: F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : F.final.status };
276
+ }
277
+ function buildFinish(m, p, g) {
278
+ const [x, y, w, d, h] = p;
279
+ const el = newEl(m.id, 'finish', g, { x, y, w, d, h }, { name: m.name });
280
+ const info = finishInfo(m.id);
281
+ setStatus(el, finishStatus(m.id, info));
282
+ body(el, { r: 7 });
283
+ el.roof = label(el, w - 24, d - 28, 4, (cx, s, C, e) => {
284
+ const fi = finishInfo(e.id), sel = e.cur.sel > .5;
285
+ if (fi.status === 'NONE') { cx.setLineDash([2.4 * s, 1.8 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, w - 25, d - 29, 5); cx.stroke(); cx.setLineDash([]); }
286
+ E.font(cx, 15, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(fi.title || '', 4 * s, 17 * s);
287
+ const sub = E.fit(fi.sub || '', w - 34, 22, 9.5, 2, 5); if (sub) E.lines(cx, s, sub, 4, 23, C.ink, w - 34, 400);
288
+ if (fi.findings) { let bx = 4; for (const sev of ['P0', 'P1', 'P2', 'P3']) if (fi.findings[sev]) bx += chip(cx, s, C, bx, 46, `${sev}×${fi.findings[sev]}`, E.statusHex(SEV_STATUS[sev])) + 3;
289
+ if (fi.findings.plausible) chip(cx, s, C, bx, 46, `~${fi.findings.plausible}`, E.statusHex('pending'), { fill: false }); }
290
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
291
+ if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
292
+ else if (m.id === 'f:integration') leds(el, Math.min(40, list(st.run.tasks).length), x + 17, y + d - 13, h + .16, { step: 8, s: 2.4, perRow: 22 });
293
+ else leds(el, Math.max(1, Math.min(12, list(st.run.verification && st.run.verification.runs).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
294
+ finishLeds(el);
295
+ display(el, e => finishInfo(e.id).line || '', { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
296
+ halo(el);
297
+ return el;
298
+ }
299
+ function finishStatus(id, info) {
300
+ if (st.replay !== null && st.replayState) return id === 'f:final' ? (st.run.final_review || st.run.settings && st.run.settings.final_qa ? st.replayState.final : 'NONE') : id === 'f:verification' ? st.replayState.verification : st.replayState.integration;
301
+ return info.status || 'PENDING';
302
+ }
303
+ function finishLeds(el) {
304
+ if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? SEV_STATUS[s].toUpperCase() : null)); }
305
+ else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
306
+ else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
307
+ }
308
+ function buildPlan(g) {
309
+ const P = RM.planModel(st.run); st.plan = P;
310
+ const names = new Map(P.layers.map(l => [l.id, l.name]));
311
+ P.plateG = plateFor(g, P.px, P.py, P.districts, d => names.get(d.layer));
312
+ for (const m of P.modules) {
313
+ const p = P.place[m.id]; if (!p) continue;
314
+ if (m.kind === 'task') buildTask(m, p, g); else buildFinish(m, p, g);
315
+ }
316
+ for (const f of P.flows) {
317
+ const a = EL.get(f.from), b = EL.get(f.to); if (!a || !b) continue;
318
+ const c = cable(g, a.n, b.n, { kind: f.kind }); c.from = f.from; c.to = f.to; c.g = 'plan';
319
+ }
320
+ for (const el of EL.values()) if (el.g === g && el.kind === 'task') PROG.set(el.id, { p: 0, t: 0 });
321
+ }
322
+
323
+ /* ---------- inside a task: attempts, dwarves, reviews ---------- */
324
+ function buildInner(el) {
325
+ const task = el.data.task, tp = RM.taskParts(task), { x, y, w, d } = el.n;
326
+ const G = el.inner = new THREE.Group(); G.visible = false; el.root.add(G);
327
+ el.innerKey = RM.innerKey(task); el.kids = [];
328
+ el.floorMat = E.recessMat.clone();
329
+ { const sh = new THREE.Shape(); E.roundRect(sh, x, -(y + d), w, d, 9);
330
+ const fg = new THREE.ShapeGeometry(sh, 8); fg.rotateX(-Math.PI / 2); fg.translate(0, .2, 0); fg.scale(TS, TS, TS);
331
+ const floor = new THREE.Mesh(fg, el.floorMat); floor.userData = { el: el.id, floor: true }; G.add(floor); PICK.push(floor); }
332
+ el.innerLine = E.lmat(); E.loopAt(x, y, w, d, 9, .26, el.innerLine, G);
333
+ const C = Math.max(1, tp.zones.length), R = Math.max(1, ...tp.zones.map(z => z.parts.length));
334
+ const padX = 10, padT = 22, padB = 10, gx = 8, gy = 7;
335
+ const cw = (w - 2 * padX - (C - 1) * gx) / C, ch = Math.min(46, (d - padT - padB - (R - 1) * gy) / R);
336
+ el.zoneEls = [];
337
+ tp.zones.forEach((z, ci) => {
338
+ const zx = x + padX + ci * (cw + gx), zy = y + padT;
339
+ const zm = E.lmat({ dashed: true, dashSize: 4 * TS, gapSize: 3 * TS });
340
+ E.loopAt(zx - 3, zy - 13, cw + 6, R * ch + (R - 1) * gy + 16, 5, .32, zm, G, 6).computeLineDistances();
341
+ const nm = z.name, f = Math.min(6.5, cw / (nm.length * (E.CW + .12) + 2));
342
+ const t = E.tplane(Math.min(cw, nm.length * f * (E.CW + .12) + 4), f * 1.5, E.pxIn(w, d), (cx, s, Cc) => {
343
+ E.font(cx, f, s); cx.letterSpacing = `${(f * .12 * s).toFixed(2)}px`; cx.fillStyle = Cc.ink; cx.fillText(nm, 0, f * 1.1 * s); cx.letterSpacing = '0px';
344
+ }, G);
345
+ E.roofAt(t, zx, zy - 11, .3);
346
+ el.zoneEls.push({ mat: zm, lab: t });
347
+ z.parts.forEach((pid, ri) => {
348
+ const part = tp.parts.find(p => p.id === pid);
349
+ const ph = KIND_H[part.kind] || 14;
350
+ const n = { x: zx, y: zy + ri * (ch + gy), w: cw, d: ch, h: ph };
351
+ const k = newEl('p:' + task.id + ':' + part.id, 'part', G, n, { part, task: task.id, parent: el.id, idx: ri + ci * R });
352
+ el.root.remove(k.root); G.add(k.root);
353
+ setStatus(k, part.planned ? 'PENDING' : part.status);
354
+ const r = part.kind === 'qa' ? Math.min(cw, ch) * .3 : Math.min(4, Math.min(cw, ch) * .07);
355
+ body(k, { r });
356
+ k.roof = label(k, cw, ch, E.pxIn(w, d), (cx, s, Cc, e) => {
357
+ const pt = e.data.part, sel = e.cur.sel > .5, pad = Math.max(2, Math.min(cw, ch) * .08);
358
+ if (pt.kind === 'shadow' || pt.planned) { cx.setLineDash([2 * s, 1.5 * s]); cx.strokeStyle = sel ? Cc.acc : Cc.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, 1.2, 1.2, cw - 2.4, ch - 2.4, 2); cx.stroke(); cx.setLineDash([]); }
359
+ const f1 = Math.min(7, ch * .2, cw / (pt.label.length * E.CW + 3));
360
+ E.font(cx, f1, s, 600); cx.fillStyle = sel ? Cc.accHi : Cc.inkHi; cx.fillText(pt.label, pad * s, (pad + f1) * s);
361
+ const sp = E.fit(pt.spec || '—', cw - 2 * pad, ch - f1 * 1.6 - pad * 2, 6, 2, 2.5);
362
+ if (sp) E.lines(cx, s, sp, pad, pad + f1 * 1.35, Cc.ink, cw - 2 * pad, 400);
363
+ const fs = pt.exec && pt.exec.findings || pt.shadow && pt.shadow.findings;
364
+ if (fs && fs.worst) chip(cx, s, Cc, pad, ch - pad - 6 * 1.6, `${fs.worst}×${fs[fs.worst]}`, E.statusHex(SEV_STATUS[fs.worst]), { f: Math.min(5, ch * .14) });
365
+ }, 'cover', { x: zx, y: n.y, z: ph });
366
+ el.kids.push(k);
367
+ });
368
+ });
369
+ el.innerFlows = [];
370
+ for (const l of tp.links) {
371
+ const a = EL.get('p:' + task.id + ':' + l.from), b = EL.get('p:' + task.id + ':' + l.to); if (!a || !b) continue;
372
+ const [A, B] = E.ends(a.n, b.n, a.n.h, b.n.h);
373
+ const pts = E.curve(A, B, l.kind === 'flow' ? 3 : 6, .14);
374
+ const mat = l.kind === 'escalation' ? E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 }) : E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8, ...(l.kind === 'retry' || l.kind === 'shadow' ? { dashed: true, dashSize: 2.5 * TS, gapSize: 2 * TS } : {}) });
375
+ const line = E.polyLine(pts, mat, G); if (l.kind === 'retry' || l.kind === 'shadow') line.computeLineDistances();
376
+ const am = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
377
+ const cn = E.cone(pts, 1.6, am, G);
378
+ const col = l.kind === 'escalation' ? E.accent : l.kind === 'shadow' ? E.statusColor(l.agree === 'disagree' ? 'conflict' : l.agree === 'agree' ? 'pass' : 'unknown') : null;
379
+ const f = { kind: l.kind, line, mat, cone: cn, am, pts, cum: E.cumLen(pts), col, a: a.id, b: b.id };
380
+ if (l.label) {
381
+ const nm = l.label, fz = 4.2, tw = nm.length * fz * E.CW + 4;
382
+ const mid = pts[Math.floor(pts.length / 2)];
383
+ f.lab = E.tplane(tw, fz * 1.6, E.pxIn(w, d), (cx, s, Cc) => { E.font(cx, fz, s, 600); cx.fillStyle = Cc.accHi; cx.fillText(nm, 2 * s, fz * 1.15 * s); }, G);
384
+ E.roofAt(f.lab, mid[0] - tw / 2, mid[1] - fz, mid[2] + 1);
385
+ }
386
+ el.innerFlows.push(f);
387
+ }
388
+ syncFractal(el);
389
+ }
390
+ /* ---------- Fractal: an opened task's child tasks, a small tree floating over it ---------- */
391
+ const FRACTAL = new Map(); // task id → TaskFractal
392
+ const FR_H = { low: 12, medium: 18, high: 26 };
393
+ function syncFractal(el) {
394
+ if (!el || !el.inner || el.kind !== 'task') return;
395
+ const tid = el.data.task.id, data = FRACTAL.get(tid), nodes = data && data.available ? list(data.nodes) : [];
396
+ const sig = JSON.stringify([E.theme, nodes.map(n => [n.id, n.parent, n.ui_status, n.difficulty, n.model, n.reserved ? 1 : 0])]);
397
+ if (sig === el.fractalSig) return;
398
+ el.fractalSig = sig;
399
+ if (el.fractalG) { PICK = PICK.filter(o => !(o.userData.fractal && o.userData.fractal.task === tid)); E.disposeGroup(el.fractalG); el.fractalG = null; }
400
+ if (!nodes.length) return;
401
+ const T = RM.fractalTree(nodes), byId = new Map(nodes.map(n => [n.id, n])), { x, y, w, d, h } = el.n;
402
+ const S = 34, DX = 62, DY = 44, tw = S + (T.cols - 1) * DX, td = S + Math.max(0, T.rows - 1) * DY;
403
+ const x0 = x + w / 2 - tw / 2, y0 = y + d / 2 - td / 2, z0 = h + 70;
404
+ const G = el.fractalG = new THREE.Group(); el.inner.add(G);
405
+ const at = id => { const p = T.pos[id], n = byId.get(id); return { x: x0 + p.col * DX, y: y0 + p.row * DY, w: S, d: S, h: FR_H[String(n.difficulty || '').toLowerCase()] || 16 }; };
406
+ const cable = E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 }); cable.color.copy(E.majorMat.color).lerp(E.accent, .45);
407
+ for (const [pa, ch] of T.edges) { const A = at(pa), B = at(ch), [P, Q] = E.ends(A, B, z0 + A.h / 2, z0 + B.h / 2); E.polyLine(E.curve(P, Q, 3, .1), cable, G); }
408
+ // a stem from the task's floor to each root
409
+ const stem = E.lmat({ dashed: true, dashSize: 4 * TS, gapSize: 3 * TS }); stem.color.copy(cable.color);
410
+ for (const n of nodes) if (T.pos[n.id] && T.pos[n.id].depth === 0) { const A = at(n.id); E.polyLine([[x + w / 2, y + d / 2, 1], [A.x + S / 2, A.y + S / 2, z0]], stem, G).computeLineDistances(); }
411
+ for (const n of nodes) {
412
+ const b = at(n.id), M = E.mats(), col = E.statusColor(n.ui_status || 'unknown');
413
+ const m = E.solid(b.x, b.y, z0, b.w, b.d, b.h, 6, M, G, { fractal: { task: tid, node: n.id } });
414
+ E.paint(M, n.reserved ? .6 : 1, .6, 0, n.reserved ? .55 : 1, n.reserved ? .45 : 1, col, .3);
415
+ PICK.push(m);
416
+ const lab = E.tplane(S - 4, 13, E.pxIn(w, d), (cx, sc, C) => {
417
+ E.font(cx, 4.6, sc, 600); cx.fillStyle = C.inkHi; cx.fillText(E.ellipsis(n.id, 12), 1 * sc, 5.4 * sc);
418
+ E.font(cx, 3.6, sc); cx.fillStyle = C.ink; cx.fillText(E.ellipsis(n.reserved ? 'reserved' : n.model || n.status || '', 14), 1 * sc, 11 * sc);
419
+ }, G, M);
420
+ E.roofAt(lab, b.x + 2, b.y + 2, z0 + b.h);
421
+ }
422
+ }
423
+ E.themeHooks.add(() => { for (const el of EL.values()) if (el.fractalG) { el.fractalSig = ''; syncFractal(el); } });
424
+ function closeInner(el) {
425
+ if (!el.inner) return;
426
+ if (el.kind === 'task') { const tid = el.data.task.id; PICK = PICK.filter(o => !(o.userData.fractal && o.userData.fractal.task === tid)); el.fractalG = null; el.fractalSig = ''; }
427
+ for (const k of el.kids || []) { EL.delete(k.id); PICK = PICK.filter(o => o.userData.el !== k.id); }
428
+ PICK = PICK.filter(o => !(o.userData.floor && o.userData.el === el.id));
429
+ E.disposeGroup(el.inner); el.inner = null; el.kids = []; el.innerFlows = [];
430
+ }
431
+
432
+ /* ---------- exec: attempts on wall-clock time ---------- */
433
+ function execSig(X) { return JSON.stringify([X.items.map(i => [i.id, i.lane, i.ghost ? 1 : 0, i.segs.length]), X.extra.map(e => e.id), X.lanes, X.t0, X.t1]); }
434
+ function buildExec(g) {
435
+ const X = RM.execModel(st.run, { now: nowSec(), ghosts: st.overlay.ghosts }); st.exec = X;
436
+ st.execSig = execSig(X);
437
+ const dists = [];
438
+ for (let i = 0; i < X.lanes; i++) dists.push({ layer: 'lane' + i, x: 6, y: 60 + i * X.laneD + 4, w: X.px - 12, d: X.laneD - 8, warn: i >= X.cap, name: `LANE ${i + 1}${i >= X.cap ? ' · over --max-parallel ' + X.cap : ''}` });
439
+ for (const fl of X.finishLanes || []) dists.push({ layer: fl.kind, x: 6, y: fl.y + 4, w: X.px - 12, d: X.laneD - 8, name: fl.name });
440
+ X.plateG = plateFor(g, X.px, X.py, dists, d => d.name);
441
+ // the time ruler along the back edge
442
+ const tk = [];
443
+ for (const r of X.ruler) tk.push([[r.x, 30, .2], [r.x, 50, .2]]);
444
+ if (tk.length) E.seg(tk, X.plateG, E.majorMat);
445
+ for (const r of X.ruler) {
446
+ const t = E.tplane(40, 10, 4, (cx, s, C) => { E.font(cx, 7, s); cx.fillStyle = C.ink; cx.fillText('+' + r.label, 1 * s, 8 * s); }, X.plateG);
447
+ E.roofAt(t, r.x + 2, 30, .1);
448
+ }
449
+ if (!X.items.length && !X.extra.length) {
450
+ const t = E.tplane(400, 20, 3, (cx, s, C) => { E.font(cx, 12, s); cx.fillStyle = C.ink; cx.fillText('Nothing has run yet: no attempt timings recorded.', 4 * s, 14 * s); }, X.plateG);
451
+ E.roofAt(t, 60, 80, .1);
452
+ }
453
+ for (const it of X.items.concat(X.extra)) buildAttempt(it, g);
454
+ // now: a pane of light across the lanes, moving with the clock (or the replay)
455
+ const nowM = new THREE.MeshBasicMaterial({ color: E.accent.clone(), toneMapped: false, transparent: true, opacity: .16, depthWrite: false, side: THREE.DoubleSide });
456
+ const pane = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry(1, 1), nowM));
457
+ pane.rotation.y = Math.PI / 2; pane.scale.set((X.py - 40) * TS, 70 * TS, 1);
458
+ pane.position.copy(W(0, (X.py + 40) / 2, 35)); g.add(pane);
459
+ const lineM = E.accentLineMat;
460
+ const nl = E.polyLine([[0, 40, .4], [0, X.py, .4]], lineM, g);
461
+ X.now = { pane, nl, mat: nowM };
462
+ placeNow();
463
+ }
464
+ function placeNow() {
465
+ const X = st.exec; if (!X || !X.now) return;
466
+ const t = st.replay !== null ? st.replay : nowSec();
467
+ const show = X.t0 !== null && (st.replay !== null || X.live) && t >= X.t0 - 1 && t <= X.t1 + 1;
468
+ X.now.pane.visible = X.now.nl.visible = show;
469
+ if (!show) return;
470
+ const xx = X.x(t);
471
+ X.now.pane.position.x = xx * TS; X.now.nl.position.x = xx * TS;
472
+ }
473
+ function attemptLine(el) {
474
+ const it = el.data.item;
475
+ if (it.ghost) return `projected · ~${RM.fmtDur(it.end - it.start)}`;
476
+ if (it.kind === 'review') return `${el.status} · ${RM.fmtDur(it.end - it.start)}`;
477
+ const bits = [el.status, RM.fmtDur(it.end - it.start)];
478
+ if (it.running && it.role) bits.splice(1, 0, it.role === 'qa' ? 'QA' : 'dwarf');
479
+ return bits.join(' · ');
480
+ }
481
+ function buildAttempt(it, g) {
482
+ const h = it.ghost ? 26 : it.kind ? 30 : 36;
483
+ const n = { x: it.x, y: it.y, w: it.w, d: it.d, h };
484
+ const id = 'x:' + it.id;
485
+ const el = newEl(id, it.ghost ? 'ghost' : it.kind || 'attempt', g, n, { item: it });
486
+ setStatus(el, it.status);
487
+ body(el, { r: Math.min(7, it.w / 4) });
488
+ const lw = Math.max(10, it.w - 10);
489
+ el.roof = label(el, lw, it.d - 14, 4, (cx, s, C, e) => {
490
+ const item = e.data.item, sel = e.cur.sel > .5;
491
+ const title = item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
492
+ const f = Math.min(11, Math.max(4, (lw - 4) / Math.max(4, title.length * E.CW)));
493
+ E.font(cx, f, s, 600); cx.fillStyle = sel ? C.accHi : item.ghost ? C.ink : C.inkHi; cx.fillText(E.ellipsis(title, Math.floor((lw - 2) / (f * E.CW))), 1 * s, (f + 1) * s);
494
+ // segments: when the dwarf and the review ran inside this attempt
495
+ const t0 = item.start, span = Math.max(1, item.end - item.start), by = it.d - 34;
496
+ for (const sg of item.segs) {
497
+ const sx = (sg.start - t0) / span * (lw - 2), sw = Math.max(1.5, ((sg.end ?? item.end) - sg.start) / span * (lw - 2));
498
+ cx.fillStyle = E.statusHex(sg.status); cx.globalAlpha = sg.role === 'qa' ? .95 : .6;
499
+ E.rr(cx, s, 1 + sx, by, Math.min(sw, lw - 2 - sx), 8, 2); cx.fill(); cx.globalAlpha = 1;
500
+ if (sw > 26) { E.font(cx, 5.5, s, 600); cx.fillStyle = C.bg; cx.fillText(sg.role === 'qa' ? 'QA' : 'DWARF', (3 + sx) * s, (by + 6) * s); }
501
+ }
502
+ if (item.findings && item.findings.worst) chip(cx, s, C, 1, by - 12, `${item.findings.worst}×${item.findings[item.findings.worst]}`, E.statusHex(SEV_STATUS[item.findings.worst]), { f: 5 });
503
+ if (item.ghost) { cx.setLineDash([2 * s, 1.6 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, lw - 1, it.d - 15, 3); cx.stroke(); cx.setLineDash([]); }
504
+ }, 'cover', { x: it.x + 5, y: it.y + 6, z: h });
505
+ if (it.w > 70) display(el, attemptLine, { w: Math.min(it.w - 10, 150), x: it.x + 5, y: it.y + it.d, z: h - 6 });
506
+ halo(el);
507
+ return el;
508
+ }
509
+
510
+ /* ---------- building a view ---------- */
511
+ function clearWorld() {
512
+ if (world) E.disposeGroup(world);
513
+ world = new THREE.Group(); E.scene.add(world);
514
+ EL = new Map(); PICK = []; FLOWS = []; PROG = new Map();
515
+ st.plan = st.exec = st.atlas = null; st.fade = null;
516
+ hideTip();
517
+ }
518
+ function buildRun() {
519
+ clearWorld(); st.rebuilds++;
520
+ const pg = new THREE.Group(), xg = new THREE.Group(); world.add(pg, xg);
521
+ st.planG = pg; st.execG = xg;
522
+ buildPlan(pg); buildExec(xg);
523
+ pg.visible = st.mode === 'plan'; xg.visible = st.mode === 'exec';
524
+ computeCritical();
525
+ retarget();
526
+ }
527
+ function rebuildExec() {
528
+ for (const [id, el] of [...EL]) if (el.g === st.execG) EL.delete(id);
529
+ PICK = PICK.filter(o => { let p = o; while (p) { if (p === st.execG) return false; p = p.parent; } return true; });
530
+ E.disposeGroup(st.execG); st.execG = new THREE.Group(); world.add(st.execG);
531
+ buildExec(st.execG); st.execG.visible = st.mode === 'exec' || !!st.fade; st.rebuilds++; st.execDirty = false;
532
+ }
533
+ function boxOf(which) {
534
+ const P = which === 'exec' ? st.exec : st.plan;
535
+ if (!P) return [-34, -34, -16, 900, 500, 60];
536
+ return [-34, -34, -16, P.px + 34, P.py + 34, 36];
537
+ }
538
+
539
+ /* ---------- selection, opening, statuses → targets ---------- */
540
+ function selIds() {
541
+ const s = st.sel, out = new Set(); if (!s) return out;
542
+ if (st.view === 'atlas') { if (s.kind === 'run') out.add('r:' + s.key); return out; }
543
+ const tid = s.kind === 'task' ? s.id : s.kind === 'execution' || s.kind === 'fractal' ? s.task : null;
544
+ if (tid) {
545
+ out.add('t:' + tid);
546
+ if (s.kind === 'execution') for (const el of EL.values()) if (el.kind === 'part' && el.data.task === tid && el.data.part.exec && el.data.part.exec.id === s.exec) out.add(el.id);
547
+ }
548
+ if (s.kind === 'task' || s.kind === 'execution' || s.kind === 'fractal') {
549
+ for (const el of EL.values()) {
550
+ const it = el.data && el.data.item; if (!it) continue;
551
+ if (s.kind !== 'execution' ? it.task === tid : it.id === s.exec || list(it.segs).some(g => g.id === s.exec)) out.add(el.id);
552
+ }
553
+ }
554
+ if (s.kind === 'final') { out.add('f:final'); for (const el of EL.values()) if (el.data && el.data.item && el.data.item.kind === 'review' && (!s.name || el.data.item.review === s.name)) out.add(el.id); }
555
+ if (s.kind === 'verification') { out.add('f:verification'); for (const el of EL.values()) if (el.data && el.data.item && el.data.item.kind === 'verification') out.add(el.id); }
556
+ if (s.kind === 'integration') out.add('f:integration');
557
+ return out;
558
+ }
559
+ function metricColor(el) {
560
+ const o = st.overlay, t = el.data && el.data.task; if (!t || o.color === 'status') return null;
561
+ if (o.color === 'model') { const a = RM.alias(t.dwarf) || '?'; const i = [...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % MODEL_PALETTE.length; return new THREE.Color(MODEL_PALETTE[i]); }
562
+ const v = RM.metricValue(t, o.color, st.run, nowSec()), mx = st.metricMax && st.metricMax[o.color];
563
+ if (v === null || !mx) return E.DETAIL.clone();
564
+ return new THREE.Color().copy(E.statusColor('pending')).lerp(E.accent, Math.sqrt(v / mx));
565
+ }
566
+ function retarget() {
567
+ const sel = selIds(), tsel = st.sel && (st.sel.kind === 'task' ? st.sel.id : st.sel.kind === 'execution' || st.sel.kind === 'fractal' ? st.sel.task : null), open = tsel && st.mode === 'plan' && st.view === 'run' ? 't:' + tsel : null;
568
+ const crit = st.overlay.critical && st.crit ? new Set(st.crit.ids.map(id => 't:' + id)) : new Set();
569
+ if (st.view === 'run' && st.overlay.height !== 'none') {
570
+ st.metricMax = st.metricMax || {};
571
+ st.metricMax[st.overlay.height] = Math.max(0, ...list(st.run.tasks).map(t => RM.metricValue(t, st.overlay.height, st.run, nowSec()) || 0));
572
+ }
573
+ if (st.view === 'run' && st.overlay.color !== 'status' && st.overlay.color !== 'model') {
574
+ st.metricMax = st.metricMax || {};
575
+ st.metricMax[st.overlay.color] = Math.max(0, ...list(st.run.tasks).map(t => RM.metricValue(t, st.overlay.color, st.run, nowSec()) || 0));
576
+ }
577
+ for (const el of EL.values()) {
578
+ baseTargets(el);
579
+ const T = el.tgt;
580
+ T.sel = sel.has(el.id) ? 1 : 0;
581
+ T.hov = st.hover === el.id ? 1 : 0;
582
+ T.halo = el.pulse ? .22 : sel.has(el.id) ? .16 : 0;
583
+ if (el.kind === 'run' && el.data.module && !el.data.module.match) { T.vis = .14; T.op = .45; T.halo = 0; }
584
+ if (el.kind === 'ghost') { T.opB = .08; T.op = .7; T.lit = 0; T.tint = 0; }
585
+ if (crit.has(el.id)) { T.lit = Math.max(T.lit, .75); T.halo = Math.max(T.halo, .12); }
586
+ if (st.overlay.critical && st.crit && el.kind === 'task' && !crit.has(el.id)) T.vis = Math.min(T.vis, .55);
587
+ el.metricCol = metricColor(el);
588
+ if (el.metricCol) { T.tint = E.theme === 'light' ? .35 : .45; T.lit = .5; }
589
+ if (el.kind === 'task' && st.overlay.height !== 'none') {
590
+ const v = RM.metricValue(el.data.task, st.overlay.height, st.run, nowSec()), mx = st.metricMax[st.overlay.height];
591
+ T.hk = v === null || !mx ? .35 : .4 + 1.4 * Math.sqrt(v / mx);
592
+ } else T.hk = 1;
593
+ if (st.replay !== null && el.data && el.data.item && !el.data.item.ghost) {
594
+ const it = el.data.item;
595
+ if (it.start > st.replay) { T.vis = .22; T.op = .35; T.opB = .25; }
596
+ }
597
+ if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = el.id === open ? 1 : 0; }
598
+ // an open task has the stage: the other blocks sink and fade, as archivision does when a block opens
599
+ if (open && el.id !== open && (el.kind === 'task' || el.kind === 'finish') && !sel.has(el.id)) { T.vis = Math.min(T.vis, .3); T.hk *= .4; T.halo = 0; }
600
+ }
601
+ for (const f of FLOWS) {
602
+ f.hot = (st.hover && (f.from === st.hover || f.to === st.hover)) || sel.has(f.from) || sel.has(f.to) ? 1 : 0;
603
+ f.crit = st.overlay.critical && st.crit && crit.has(f.from) && crit.has(f.to) && st.crit.ids.indexOf(f.from.slice(2)) === st.crit.ids.indexOf(f.to.slice(2)) - 1 ? 1 : 0;
604
+ const tgtEl = EL.get(f.to);
605
+ f.live = tgtEl && tgtEl.status === 'RUNNING' && f.kind === 'dep';
606
+ }
607
+ E.invalidate(200);
608
+ }
609
+ function computeCritical() { st.crit = st.view === 'run' && st.run && st.run.kind !== 'solo' ? RM.criticalPath(st.run, { now: nowSec() }) : null; }
610
+
611
+ /* ---------- every frame: ease toward the targets, open, light, move beads ---------- */
612
+ const BEADS = [];
613
+ const beadMat = new THREE.MeshBasicMaterial({ color: E.accent.clone(), toneMapped: false });
614
+ const beadG = new THREE.Group(); E.scene.add(beadG);
615
+ for (let i = 0; i < 24; i++) {
616
+ const m = E.noRay(new THREE.Mesh(new THREE.SphereGeometry(TS, 12, 8), beadMat)); m.visible = false; beadG.add(m);
617
+ const gl = E.glowPoints([new THREE.Vector3()], .3); gl.visible = false; beadG.add(gl); BEADS.push({ m, gl });
618
+ }
619
+ E.themeHooks.add(() => { beadMat.color.copy(E.accent); for (const el of EL.values()) { el.col = E.statusColor(el.status); halo(el); } retarget(); });
620
+ let lastPulse = 0, lastText = 0;
621
+ function frameStep(now, dt) {
622
+ const kk = reduce ? 1 : Math.min(1, dt * 7);
623
+ let moving = false;
624
+ // the plan ⇄ exec switch: blocks travel toward where they ran, while one view fades into the other
625
+ let fk = null;
626
+ if (st.fade) {
627
+ fk = ez(clamp((now - st.fade.t0) / st.fade.dur)); moving = true;
628
+ if (fk >= 1) { st.planG.visible = st.mode === 'plan'; st.execG.visible = st.mode === 'exec'; for (const el of EL.values()) el.offset.set(0, 0, 0); st.fade = null; retarget(); fk = null; }
629
+ }
630
+ for (const [id, pr] of PROG) {
631
+ if (pr.p === pr.t) continue;
632
+ pr.p = pr.t > pr.p ? Math.min(pr.t, pr.p + dt * 1000 / 1300) : Math.max(pr.t, pr.p - dt * 1000 / 600);
633
+ if (reduce) pr.p = pr.t;
634
+ const el = EL.get(id); if (el && pr.t > 0 && !el.inner) buildInner(el);
635
+ moving = true;
636
+ }
637
+ const pulse = .5 + .5 * Math.sin(now / 420);
638
+ let pulsing = false;
639
+ for (const el of EL.values()) {
640
+ const c = el.cur, T = el.tgt;
641
+ for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk']) {
642
+ const d = T[k] - c[k]; if (Math.abs(d) > .002) { c[k] += d * kk; moving = true; } else c[k] = T[k];
643
+ }
644
+ // which view the element belongs to, during a switch
645
+ let gf = 1;
646
+ if (fk !== null) {
647
+ const inPlan = el.g === st.planG || (el.kind === 'part');
648
+ gf = st.mode === 'exec' ? (inPlan ? 1 - fk : fk) : (inPlan ? fk : 1 - fk);
649
+ const off = st.fade.off.get(el.id);
650
+ if (off) { const k = inPlan ? (st.mode === 'exec' ? fk : 1 - fk) : (st.mode === 'exec' ? 1 - fk : fk); el.offset.copy(off).multiplyScalar(k); }
651
+ }
652
+ el.root.position.copy(el.offset);
653
+ let lift = 0, ob = 1, ol = 1;
654
+ const pr = PROG.get(el.id);
655
+ if (pr) { const p = pr.p; lift = ez(segT(p, .04, .46)); ob = 1 - segT(p, .06, .3); ol = 1 - segT(p, .14, .46); }
656
+ if (el.kind === 'part') { const par = EL.get(el.data.parent), pp = par && PROG.get(par.id); const p = pp ? pp.p : 0; const [a, b] = [.34 + el.data.idx * .05, .64 + el.data.idx * .05]; const q = back(segT(p, a, b)); el.q = q; el.root.visible = q > .001; el.cover.scale.y = Math.max(.001, q); }
657
+ el.cover.visible = ol > .003;
658
+ el.cover.position.y = (el.kind === 'task' ? 120 : 0) * lift * TS;
659
+ if (el.kind === 'task' || el.kind === 'finish') el.cover.scale.y = Math.max(.05, c.hk);
660
+ const pulseK = el.pulse && st.replay === null ? pulse : el.pulse ? .6 : 0;
661
+ if (el.pulse) pulsing = true;
662
+ const col = el.metricCol || el.col;
663
+ TMP.copy(col).lerp(E.accent, c.sel);
664
+ E.paint(el.M, c.vis, Math.min(1, c.lit + c.sel * .9), c.hov, c.op * ol * gf, c.opB * ob * gf, TMP, 0);
665
+ if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
666
+ if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * 1.1 + .08 * c.sel) * ob * gf * c.vis; }
667
+ if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
668
+ if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
669
+ if (el.inner) {
670
+ const p = pr ? pr.p : 0, vis = p > .002;
671
+ if (!vis && el.inner.visible) { E.release(el.inner); }
672
+ el.inner.visible = vis;
673
+ if (vis) {
674
+ const fo = segT(p, 0, .3) * gf;
675
+ E.setOp(el.floorMat, fo); E.setOp(el.innerLine, fo); E.lineCol(el.innerLine, E.lineMat.color, 0, 1);
676
+ for (const z of el.zoneEls) { E.lineCol(z.mat, E.majorMat.color, 0, 1); z.mat.color.lerp(E.INKHI, .18); E.setOp(z.mat, fo); z.lab.mat.opacity = fo; }
677
+ for (const f of el.innerFlows) {
678
+ const qa = EL.get(f.a), qb = EL.get(f.b), o = segT(Math.min(qa ? qa.q || 0 : 0, qb ? qb.q || 0 : 0), .7, 1) * gf;
679
+ f.line.visible = f.cone.visible = o > .01;
680
+ const hot = st.sel && (sel(f.a) || sel(f.b)) ? 1 : 0;
681
+ E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.col ? 1 : hot, 1, f.col || E.accent); f.mat.linewidth = E.lineMat.linewidth * (f.col ? 1.6 : 1.15 + .5 * hot); E.setOp(f.mat, o);
682
+ f.am.color.copy(f.mat.color); f.am.opacity = o; if (f.lab) f.lab.mat.opacity = o;
683
+ }
684
+ }
685
+ }
686
+ }
687
+ for (const f of FLOWS) {
688
+ const a = EL.get(f.from), b = EL.get(f.to);
689
+ let gf = 1;
690
+ if (fk !== null) gf = st.mode === 'exec' ? 1 - fk : fk;
691
+ const vis = Math.min(a ? a.cur.vis : 1, b ? b.cur.vis : 1);
692
+ const want = Math.max(f.hot, f.crit);
693
+ f.cur += (want - f.cur) * kk; if (Math.abs(want - f.cur) > .002) moving = true;
694
+ E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.crit ? 1 : f.cur * .9, vis, E.accent);
695
+ if (!f.crit && !f.cur) f.mat.color.lerp(E.INKHI, .12);
696
+ f.mat.linewidth = E.lineMat.linewidth * (1 + .9 * Math.max(f.cur, f.crit));
697
+ E.setOp(f.mat, (.55 + .45 * Math.max(f.cur, f.crit)) * gf);
698
+ f.am.color.copy(f.mat.color); f.am.opacity = f.mat.opacity;
699
+ }
700
+ // beads: into running tasks, along the selected task's cables, inside an open running task
701
+ const want = [];
702
+ const tB = now / 1600;
703
+ for (const f of FLOWS) if ((f.live || f.hot || f.crit) && (st.mode === 'plan' || st.fade) && st.planG && st.planG.visible) want.push({ pts: f.pts, cum: f.cum, t: (tB + (f.pts[0][0] % 97) / 97) % 1, r: 3.2, size: .32, k: .8 });
704
+ for (const el of EL.values()) if (el.inner && el.inner.visible) for (const f of el.innerFlows) {
705
+ const b = EL.get(f.b); if (b && b.status === 'RUNNING' || f.kind === 'flow' && st.sel && sel(f.a)) want.push({ pts: f.pts, cum: f.cum, t: (tB * 1.4) % 1, r: 1.1, size: .12, k: .8 });
706
+ }
707
+ BEADS.forEach((bd, i) => {
708
+ const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce; if (!w || reduce) return;
709
+ const p = E.alongPts(w.pts, w.cum, w.t); bd.m.position.copy(W(...p)); bd.m.scale.setScalar(w.r);
710
+ bd.gl.material.uniforms.size.value = w.size; E.moveGlow(bd.gl, p, w.k);
711
+ });
712
+ // elapsed times on running blocks tick once a second
713
+ if (now - lastText > 1000) { lastText = now; for (const el of EL.values()) if (el.pulse && !el.roofOnly) for (const t of el.texts) if (t !== el.roof && !t.gone) t.redraw(); }
714
+ if (st.view === 'run' && st.exec && st.exec.live && st.mode === 'exec') { placeNow(); }
715
+ if (!moving && (pulsing || want.length) && !reduce) {
716
+ // only lamps and beads move: about 30 frames a second is plenty
717
+ if (now - lastPulse > 30) { lastPulse = now; setTimeout(() => E.invalidate(), 33); }
718
+ return false;
719
+ }
720
+ return moving;
721
+ }
722
+ const sel = id => selIds().has(id);
723
+ E.tickers.add(frameStep);
724
+ E.controls.addEventListener('start', hideTip);
725
+
726
+ /* ---------- pointer and keys ---------- */
727
+ const elOf = o => o && o.userData && o.userData.el ? EL.get(o.userData.el) : null;
728
+ const pickable = o => {
729
+ for (let x = o; x; x = x.parent) if (!x.visible) return false;
730
+ const el = elOf(o);
731
+ if (el && o.userData.cover) { const pr = PROG.get(el.id); if (pr && pr.p > .22) return false; if (el.kind === 'part' && (el.q || 0) < .5) return false; }
732
+ return true;
733
+ };
734
+ function info(el) {
735
+ if (!el) return null;
736
+ if (el.kind === 'run') { const r = el.data.run; return { meta: `${RM.basename(r.repo)} · ${r.kind}${el.data.module.match ? '' : ' · filtered out'}`, title: String(r.goal || 'Untitled run').split('\n')[0], body: runInfo(r), foot: 'click to open this run' }; }
737
+ if (el.kind === 'task') { const t = el.data.task; return { meta: `task · wave ${t.wave ?? '—'} · ${t.difficulty || '—'}`, title: `${t.id} · ${t.title || ''}`, body: `${el.status} · dwarf ${t.dwarf || '—'} · qa ${t.qa || '—'}`, foot: list(t.deps).length ? 'after ' + t.deps.join(', ') : 'no dependencies' }; }
738
+ if (el.kind === 'finish') { const fi = finishInfo(el.id); return { meta: 'finish', title: fi.title, body: `${fi.status} · ${fi.sub}`, foot: fi.line }; }
739
+ if (el.kind === 'part') { const p = el.data.part; return { meta: `${p.label.toLowerCase()} · attempt ${p.attempt ?? '—'}`, title: p.spec || '—', body: p.planned ? 'planned' : p.status, foot: p.exec && p.exec.id ? 'click for prompt, model and timings' : '' }; }
740
+ const it = el.data.item; if (!it) return null;
741
+ if (it.kind === 'verification') return { meta: 'verification', title: 'Verification', body: attemptLine(el) };
742
+ if (it.kind === 'review') return { meta: 'final review', title: it.review, body: attemptLine(el) };
743
+ return { meta: it.ghost ? 'projected by forge-schedule rules' : `attempt ${it.attempt ?? '—'} · lane ${it.lane + 1}`, title: it.task, body: attemptLine(el), foot: new Date(it.start * 1000).toLocaleTimeString() + ' → ' + new Date(it.end * 1000).toLocaleTimeString() };
744
+ }
745
+ function showTip(el, cx, cy) {
746
+ const inf = info(el); if (!inf) return hideTip();
747
+ tip.replaceChildren();
748
+ for (const [cls, text] of [['meta', inf.meta], ['title', inf.title], ['body', inf.body], ['foot', inf.foot]]) {
749
+ if (!text) continue; const d = document.createElement('div'); d.className = 'stage-tip-' + cls; d.textContent = text; tip.append(d);
750
+ }
751
+ tip.hidden = false;
752
+ const r = host.getBoundingClientRect(), tw = tip.offsetWidth || 240, th = tip.offsetHeight || 80;
753
+ let x = cx - r.left + 16, y = cy - r.top + 16;
754
+ if (x + tw > r.width - 8) x = cx - r.left - tw - 16;
755
+ if (y + th > r.height - 8) y = cy - r.top - th - 16;
756
+ tip.style.transform = `translate(${Math.max(4, x).toFixed(0)}px, ${Math.max(4, y).toFixed(0)}px)`;
757
+ }
758
+ function hideTip() { tip.hidden = true; }
759
+ let hoverRaf = 0, hoverAt = null;
760
+ E.handlers.hover = (cx, cy) => {
761
+ hoverAt = cx === null ? null : [cx, cy];
762
+ if (hoverRaf) return;
763
+ hoverRaf = requestAnimationFrame(() => {
764
+ hoverRaf = 0;
765
+ const o = hoverAt ? E.pick(hoverAt[0], hoverAt[1], PICK, pickable) : null;
766
+ const el = elOf(o), id = el && !(o.userData.floor) ? el.id : null;
767
+ E.canvas.classList.toggle('pointing', !!id);
768
+ if (id !== st.hover) { st.hover = id; retarget(); }
769
+ if (el && !o.userData.floor && hoverAt) showTip(el, hoverAt[0], hoverAt[1]); else hideTip();
770
+ });
771
+ };
772
+ function selFor(el) {
773
+ if (!el) return null;
774
+ if (el.kind === 'task') return { kind: 'task', id: el.data.task.id };
775
+ if (el.kind === 'part') { const p = el.data.part; return p.exec && p.exec.id && !p.planned ? { kind: 'execution', task: el.data.task, id: el.data.task, exec: p.exec.id, shadow: p.kind === 'shadow' } : { kind: 'task', id: el.data.task }; }
776
+ if (el.kind === 'finish') return { kind: el.id === 'f:final' ? 'final' : el.id === 'f:verification' ? 'verification' : 'integration' };
777
+ const it = el.data.item; if (!it) return null;
778
+ if (it.kind === 'review') return { kind: 'final', name: it.review };
779
+ if (it.kind === 'verification') return { kind: 'execution', task: null, exec: it.id };
780
+ if (it.ghost || String(it.id).startsWith('span:')) return { kind: 'task', id: it.task };
781
+ return { kind: 'execution', task: it.task, id: it.task, exec: it.id };
782
+ }
783
+ E.handlers.click = (cx, cy) => {
784
+ hideTip();
785
+ const o = E.pick(cx, cy, PICK, pickable), el = elOf(o);
786
+ if (o && o.userData.fractal) { cb.onSelect && cb.onSelect({ kind: 'fractal', task: o.userData.fractal.task, node: o.userData.fractal.node }); return; }
787
+ if (st.view === 'atlas') {
788
+ if (el && el.kind === 'run') {
789
+ st.sel = { kind: 'run', key: el.data.run.key }; retarget();
790
+ const { x, y, w, d, h } = el.n;
791
+ E.frameBox([x - 20, y - 20, 0, x + w + 20, y + d + 20, h * 2], 600, 40);
792
+ setTimeout(() => cb.onOpenRun && cb.onOpenRun(el.data.run.key), reduce ? 0 : 520);
793
+ }
794
+ return;
795
+ }
796
+ if (!el || o.userData.plate) { cb.onSelect && cb.onSelect(null); return; }
797
+ if (o.userData.floor) { cb.onSelect && cb.onSelect({ kind: 'task', id: el.data.task.id }); return; }
798
+ cb.onSelect && cb.onSelect(selFor(el));
799
+ };
800
+ function order() { return st.plan ? st.plan.modules.filter(m => m.kind === 'task').map(m => m.task.id) : []; }
801
+ E.handlers.key = e => {
802
+ if (e.key === 'Escape') { if (st.view === 'run' && st.sel) { cb.onSelect && cb.onSelect(null); return true; } if (st.view === 'run' && cb.onBack) { cb.onBack(); return true; } return false; }
803
+ if (st.view !== 'run') return false;
804
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
805
+ const ids = order(); if (!ids.length) return true;
806
+ const cur = st.sel && (st.sel.kind === 'task' ? st.sel.id : st.sel.task), d = e.key === 'ArrowRight' ? 1 : -1;
807
+ const i = ids.indexOf(cur), next = i < 0 ? (d > 0 ? 0 : ids.length - 1) : (i + d + ids.length) % ids.length;
808
+ cb.onSelect && cb.onSelect({ kind: 'task', id: ids[next] }); return true;
809
+ }
810
+ if (e.key === 'e' || e.key === 'E') { cb.onMode && cb.onMode(st.mode === 'plan' ? 'exec' : 'plan'); return true; }
811
+ return false;
812
+ };
813
+
814
+ // the cloud has its own pointer and keys: while it is shown, the engine's handlers go to it
815
+ let cloud = null;
816
+ for (const k of Object.keys(E.handlers)) {
817
+ const mine = E.handlers[k];
818
+ E.handlers[k] = (...a) => {
819
+ if (st.view === 'cloud') return cloud && cloud.handlers[k] ? cloud.handlers[k](...a) : k === 'grab' ? null : false;
820
+ return mine ? mine(...a) : k === 'grab' ? null : false;
821
+ };
822
+ }
823
+ const leaveCloud = () => { if (cloud) cloud.detach(); };
824
+
825
+ /* ---------- public API ---------- */
826
+ function frameSelection(dur = 850) {
827
+ if (st.view !== 'run') return;
828
+ const ids = [...selIds()].map(id => EL.get(id)).filter(el => el && el.root.parent && (st.mode === 'plan' ? el.g === st.planG || el.kind === 'part' : el.g === st.execG));
829
+ if (!ids.length) { E.frameBox(boxOf(st.mode), dur, 26); return; }
830
+ hideTip();
831
+ const xs = ids.flatMap(el => [el.n.x, el.n.x + el.n.w]), ys = ids.flatMap(el => [el.n.y, el.n.y + el.n.d]);
832
+ const hs = Math.max(...ids.map(el => el.n.h));
833
+ // keep the neighbours in view: the selection fills about half the stage, not all of it
834
+ const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
835
+ const pad = Math.max(st.mode === 'exec' ? 80 : 40, span * .3);
836
+ const opening = st.mode === 'plan' && ids.some(el => el.kind === 'task');
837
+ const tree = opening && ids.some(el => FRACTAL.has(el.data && el.data.task && el.data.task.id)) ? hs + 130 : 0; // a Fractal tree floats above
838
+ E.frameBox([Math.min(...xs) - pad, Math.min(...ys) - pad, 0, Math.max(...xs) + pad, Math.max(...ys) + pad, Math.max(tree, hs * (opening ? 2 : 1.4))], dur, opening ? 52 : 40);
839
+ }
840
+ const api = {
841
+ showAtlas(runs, filter, opts = {}) {
842
+ st.runs = list(runs); st.filter = filter || {}; st.atlasOpts = { showAll: !!opts.showAll };
843
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
844
+ const sig = JSON.stringify(A.modules.map(m => [m.id, m.tasks.length, m.layer]));
845
+ if (st.view !== 'atlas' || sig !== st.atlasSig) {
846
+ leaveCloud(); st.view = 'atlas'; st.run = null; st.sel = null; clearWorld(); st.atlasSig = sig; st.rebuilds++; buildAtlas();
847
+ } else updateAtlas();
848
+ E.canvas.setAttribute('aria-label', `Map of ${A.modules.length} runs, ${A.matches} matching the filter${A.hidden ? `, ${A.hidden} older runs hidden` : ''}`);
849
+ retarget();
850
+ return { hidden: A.hidden, total: A.total, matches: A.matches, shown: A.modules.length };
851
+ },
852
+ showRun(run, { mode, sel } = {}) {
853
+ if (mode) st.mode = mode;
854
+ const key = RM.structureKey(run), first = st.view !== 'run' || !st.run || st.run.key !== run.key;
855
+ st.run = run; st.replayState = st.replay !== null ? RM.stateAt(run, st.replay) : null;
856
+ const selChanged = sel !== undefined && JSON.stringify(sel || null) !== JSON.stringify(st.sel || null);
857
+ if (sel !== undefined) st.sel = sel;
858
+ if (selChanged && !first && key === st.structure) { api.update(run); retarget(); frameSelection(); return; }
859
+ if (first || key !== st.structure) {
860
+ leaveCloud(); st.view = 'run'; st.structure = key; buildRun();
861
+ if (first) {
862
+ const b = boxOf(st.mode);
863
+ if (!reduce) { E.frameBox(b, 0, 26); }
864
+ if (st.sel) frameSelection(first ? 1100 : 850); else E.frameBox(b, first ? 1000 : 700, 26);
865
+ }
866
+ } else api.update(run);
867
+ E.canvas.setAttribute('aria-label', `Run ${run.run_id || ''}: ${list(run.tasks).length} tasks, ${st.mode === 'plan' ? 'planned waves' : 'execution timeline'}`);
868
+ retarget();
869
+ },
870
+ update(run) {
871
+ if (st.view !== 'run' || !run || !st.run || run.key !== st.run.key) return;
872
+ if (RM.structureKey(run) !== st.structure) { api.showRun(run); return; }
873
+ st.run = run; st.replayState = st.replay !== null ? RM.stateAt(run, st.replay) : null;
874
+ st.plan.finish = RM.finishState(run);
875
+ const byId = new Map(list(run.tasks).map(t => [t.id, t]));
876
+ for (const el of EL.values()) {
877
+ if (el.kind === 'task') {
878
+ const t = byId.get(el.data.task.id); if (!t) continue;
879
+ el.data.task = t; setStatus(el, taskStatus(t)); halo(el);
880
+ const rows = list(t.attempts).map(a => a.status);
881
+ if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
882
+ if (rows.length > el.ledCount) { /* more tries than lamps: the newest show */ }
883
+ setLeds(el, rows.slice(-el.ledCount));
884
+ const sig = JSON.stringify([t.status, t.findings, t.gate, list(t.shadow).map(s => s.agreement), t.dwarf, t.accepted_by, el.status]);
885
+ if (sig !== el.sig) { el.sig = sig; redraw(el); }
886
+ if (el.inner && RM.innerKey(t) !== el.innerKey) { closeInner(el); buildInner(el); st.rebuilds++; }
887
+ if (el.kids) for (const k of el.kids) { const np = RM.taskParts(t).parts.find(p => p.id === k.data.part.id); if (np) { k.data.part = np; setStatus(k, np.planned ? 'PENDING' : np.status); redraw(k); } }
888
+ } else if (el.kind === 'finish') {
889
+ setStatus(el, finishStatus(el.id, finishInfo(el.id))); halo(el); finishLeds(el);
890
+ const sig = JSON.stringify([finishInfo(el.id), el.status]); if (sig !== el.sig) { el.sig = sig; redraw(el); }
891
+ }
892
+ }
893
+ const X = RM.execModel(run, { now: nowSec(), ghosts: st.overlay.ghosts, scale: st.exec && { t0: st.exec.t0, t1: st.exec.t1 } });
894
+ // the hidden view is rebuilt when it is next shown, not on every poll that changes its shape
895
+ if (execSig(X) !== st.execSig) { if (st.mode === 'exec' || st.fade) rebuildExec(); else st.execDirty = true; }
896
+ else {
897
+ st.exec.items = X.items; st.exec.extra = X.extra; st.exec.live = X.live;
898
+ const byItem = new Map(X.items.concat(X.extra).map(i => [i.id, i]));
899
+ for (const el of EL.values()) if (el.data && el.data.item) {
900
+ const it = byItem.get(el.data.item.id); if (!it) continue;
901
+ const grew = Math.abs((it.end - it.start) - (el.data.item.end - el.data.item.start)) * X.k > 4;
902
+ el.data.item = it; setStatus(el, st.replay !== null ? replayItemStatus(it) : it.status); halo(el);
903
+ const sig = JSON.stringify([it.status, it.segs.map(s => [s.status, Math.round(s.end || 0)]), it.role]);
904
+ if (grew) { rebuildItem(el, it); } else if (sig !== el.sig) { el.sig = sig; redraw(el); }
905
+ }
906
+ }
907
+ computeCritical();
908
+ retarget();
909
+ },
910
+ setMode(mode) {
911
+ if (mode === st.mode || st.view !== 'run') { st.mode = mode; return; }
912
+ const from = st.mode; st.mode = mode;
913
+ if (mode === 'exec' && st.execDirty) { st.execDirty = false; rebuildExec(); }
914
+ // where each task travels: its plan block ↔ its latest attempt on the time axis
915
+ const off = new Map();
916
+ for (const el of EL.values()) {
917
+ if (el.kind !== 'task') continue;
918
+ const its = [...EL.values()].filter(x => x.data && x.data.item && x.data.item.task === el.data.task.id);
919
+ const last = its.sort((a, b) => a.data.item.start - b.data.item.start).pop(); if (!last) continue;
920
+ const dx = (last.n.x + last.n.w / 2) - (el.n.x + el.n.w / 2), dy = (last.n.y + last.n.d / 2) - (el.n.y + el.n.d / 2);
921
+ off.set(el.id, W(dx, dy, 0));
922
+ for (const x of its) off.set(x.id, W(-dx, -dy, 0));
923
+ }
924
+ st.planG.visible = st.execG.visible = true;
925
+ for (const el of EL.values()) if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = 0; }
926
+ st.fade = reduce ? null : { t0: performance.now(), dur: 900, from, off };
927
+ if (reduce) { st.planG.visible = mode === 'plan'; st.execG.visible = mode === 'exec'; }
928
+ st.plan.plateG.visible = mode === 'plan'; st.exec.plateG.visible = mode === 'exec';
929
+ retarget();
930
+ if (st.sel) frameSelection(900); else E.frameBox(boxOf(mode), 900, 26);
931
+ E.invalidate(950);
932
+ },
933
+ setOverlay(o) { Object.assign(st.overlay, o || {}); if (st.view === 'run') { if ('ghosts' in (o || {})) rebuildExec(); computeCritical(); for (const el of EL.values()) redraw(el); } retarget(); },
934
+ setReplay(t) {
935
+ st.replay = t === null || t === undefined ? null : Number(t);
936
+ if (st.view !== 'run') return;
937
+ st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
938
+ for (const el of EL.values()) {
939
+ if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
940
+ else if (el.kind === 'finish') setStatus(el, finishStatus(el.id, finishInfo(el.id)));
941
+ else if (el.data && el.data.item) setStatus(el, st.replay !== null ? replayItemStatus(el.data.item) : el.data.item.status);
942
+ else continue;
943
+ halo(el); if (el.kind === 'finish') finishLeds(el);
944
+ const sig = 'replay:' + el.status; if (sig !== el.sig) { el.sig = sig; redraw(el); }
945
+ }
946
+ placeNow(); retarget();
947
+ },
948
+ select(s, { frame = true } = {}) {
949
+ const same = JSON.stringify(s || null) === JSON.stringify(st.sel || null);
950
+ st.sel = s || null; retarget();
951
+ if (frame && !same && st.view === 'run') frameSelection();
952
+ },
953
+ // runs: every RunSummary; opts: {filter, layout, shape, sel, focus} (see cloud3d.js)
954
+ showCloud(runs, opts = {}) {
955
+ if (st.view !== 'cloud') { clearWorld(); st.view = 'cloud'; st.run = null; st.sel = null; st.atlasSig = ''; st.structure = ''; }
956
+ if (!cloud) cloud = createCloud(E, host, cb.cloud || {});
957
+ const c = cloud.show(list(runs), opts);
958
+ E.canvas.setAttribute('aria-label', `Cloud of runs: ${c.visible} of ${c.total} runs in ${c.projects} projects, ${c.layout} layout`);
959
+ return c;
960
+ },
961
+ get cloud() { return cloud; },
962
+ // a task's Fractal tree (TaskFractal, or null), drawn over the task while it is open
963
+ setFractal(taskId, data) {
964
+ if (data) FRACTAL.set(taskId, data); else FRACTAL.delete(taskId);
965
+ const el = EL.get('t:' + taskId); if (el && el.inner) { syncFractal(el); E.invalidate(300); }
966
+ },
967
+ focus() { E.canvas.focus({ preventScroll: true }); },
968
+ get mode() { return st.mode; },
969
+ get view() { return st.view; },
970
+ dispose() { E.dispose(); tip.remove(); },
971
+ debug() {
972
+ return {
973
+ view: st.view, mode: st.mode, rebuilds: st.rebuilds, sel: st.sel, prog: [...PROG].map(([id, p]) => [id, p.p, p.t]), fade: !!st.fade,
974
+ nodes: [...EL.values()].map(el => ({ id: el.id, kind: el.kind, status: el.status, sel: el.tgt.sel, vis: el.tgt.vis })),
975
+ screenPos(id) { const el = EL.get(id); if (!el) return null; const r = E.canvas.getBoundingClientRect(); const p = E.project(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h); return { x: r.left + p.x, y: r.top + p.y, on: p.on }; },
976
+ settle() { for (const el of EL.values()) Object.assign(el.cur, el.tgt); for (const pr of PROG.values()) pr.p = pr.t; E.invalidate(); },
977
+ };
978
+ },
979
+ };
980
+ function replayItemStatus(it) {
981
+ const t = st.replay; if (it.ghost) return 'PENDING';
982
+ if (t < it.start) return 'PENDING';
983
+ if (t < it.end && (it.running || it.end > t)) return 'RUNNING';
984
+ return it.status;
985
+ }
986
+ function rebuildItem(el, it) {
987
+ const g = el.g;
988
+ EL.delete(el.id); PICK = PICK.filter(o => o.userData.el !== el.id);
989
+ E.disposeGroup(el.root);
990
+ const X = st.exec, k = X.k;
991
+ it.x = X.x(it.start); it.w = Math.max(22, (it.end - it.start) * k);
992
+ const nel = buildAttempt(it, g);
993
+ Object.assign(nel.cur, el.cur);
994
+ }
995
+ return api;
996
+ }