@giamme/forge 0.10.0 → 0.11.1

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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +13 -1
  5. package/docs/commands.md +11 -5
  6. package/docs/dataset.md +13 -0
  7. package/docs/decomposed-runs.md +13 -3
  8. package/docs/forge-ui.md +8 -3
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +73 -2
  11. package/forge +16 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +86 -4
  14. package/references/decompose.md +24 -6
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +4 -1
  17. package/scripts/forge-config.py +5 -2
  18. package/scripts/forge-control.py +4 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +345 -17
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +132 -14
  24. package/scripts/forge-parallel.sh +96 -11
  25. package/scripts/forge-prompt.py +61 -4
  26. package/scripts/forge-route.py +72 -12
  27. package/scripts/forge-runtime.py +47 -1
  28. package/scripts/forge-ui.py +8 -4
  29. package/scripts/forge_dataset/__init__.py +7 -2
  30. package/scripts/forge_dataset/cli.py +44 -1
  31. package/scripts/forge_dataset/export.py +36 -3
  32. package/scripts/forge_dataset/forecast.py +355 -0
  33. package/scripts/forge_dataset/harvest.py +23 -2
  34. package/scripts/forge_dataset/importer.py +20 -8
  35. package/scripts/forge_dataset/reviewers.py +145 -0
  36. package/scripts/forge_dataset/routing.py +1 -0
  37. package/scripts/forge_jev/__init__.py +7 -3
  38. package/scripts/forge_registry.py +15 -10
  39. package/ui/README.md +5 -4
  40. package/ui/docs/api.md +88 -23
  41. package/ui/forge_ui/control.py +26 -5
  42. package/ui/forge_ui/reader.py +184 -11
  43. package/ui/forge_ui/static/app.js +24 -10
  44. package/ui/forge_ui/static/cloud3d.js +4 -1
  45. package/ui/forge_ui/static/cloudmodel.js +1 -1
  46. package/ui/forge_ui/static/cloudui.js +13 -4
  47. package/ui/forge_ui/static/findingsview.js +65 -14
  48. package/ui/forge_ui/static/graph.js +6 -5
  49. package/ui/forge_ui/static/leaderboard.js +18 -0
  50. package/ui/forge_ui/static/modelstats.js +19 -1
  51. package/ui/forge_ui/static/panel.js +52 -10
  52. package/ui/forge_ui/static/runmodel.js +128 -13
  53. package/ui/forge_ui/static/scene3d.js +32 -15
  54. package/ui/forge_ui/static/settings.js +48 -12
  55. package/ui/forge_ui/static/style.css +7 -3
@@ -89,12 +89,13 @@ export async function createStage(host, cb = {}) {
89
89
  el.col = col || E.statusColor(el.status);
90
90
  el.pulse = el.status === 'RUNNING';
91
91
  }
92
- const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN']);
92
+ const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN', 'OUTDATED']); // unlit: no glow and no tint
93
93
  function baseTargets(el) {
94
94
  const T = el.tgt, s = el.status, light = E.theme === 'light';
95
95
  T.lit = PLAIN.has(s) ? 0 : .32;
96
96
  T.tint = PLAIN.has(s) ? 0 : light ? .34 : .1; // a pale panel shows a lit section by its colour: no glow to carry it
97
97
  T.vis = 1; T.op = 1; T.opB = 1;
98
+ // Not started yet. OUTDATED is a finished review that is no longer current, so it stays solid.
98
99
  if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
99
100
  // parts are small: their status has to read from the colour of the whole piece
100
101
  if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
@@ -166,7 +167,8 @@ export async function createStage(host, cb = {}) {
166
167
  const parts = [r.kind === 'solo' ? (Object.keys(c)[0] || 'solo') : `${merged}/${total} done`];
167
168
  if (running) parts.push(`${running} running`);
168
169
  if (bad) parts.push(`${bad} failed`);
169
- if (r.final_review) parts.push(`review ${r.final_review}`);
170
+ const reviewStatus = RM.runReviewStatus(r);
171
+ if (reviewStatus) parts.push(`review ${reviewStatus}`);
170
172
  else if (r.verification_status) parts.push(`verify ${r.verification_status}`);
171
173
  return parts.join(' · ');
172
174
  }
@@ -191,7 +193,7 @@ export async function createStage(host, cb = {}) {
191
193
  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);
192
194
  }, 'cover', { x: x + 12, y: y + 8, z: h });
193
195
  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)) });
194
- setLeds(el, m.tasks.map(t => t.status));
196
+ setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
195
197
  display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
196
198
  halo(el);
197
199
  }
@@ -206,17 +208,28 @@ export async function createStage(host, cb = {}) {
206
208
  const el = EL.get('r:' + m.id); if (!el) continue;
207
209
  el.data.run = m.run; el.data.module = m;
208
210
  setStatus(el, STATE_STATUS[m.state]); halo(el);
209
- setLeds(el, m.tasks.map(t => t.status));
210
- const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.verification_status]);
211
+ setLeds(el, m.tasks.map(t => RM.taskStatus(t)));
212
+ const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.final_review_outdated, m.run.verification_status]);
211
213
  if (sig !== el.sig) { el.sig = sig; redraw(el); }
212
214
  }
213
215
  retarget();
214
216
  }
215
217
 
216
218
  /* ---------- plan: waves, tasks, the finish line ---------- */
217
- function taskStatus(t) {
218
- if (st.replay !== null && st.replayState) { const s = st.replayState.tasks[t.id]; return s ? s.status : 'PENDING'; }
219
- return t.status || 'PENDING';
219
+ // During a replay the recorded status is what it was then; acceptance shows only once that status is MERGED.
220
+ function shownTask(t) {
221
+ if (t && st.replay !== null && st.replayState) {
222
+ const s = st.replayState.tasks[t.id];
223
+ return Object.assign({}, t, { status: s ? s.status : 'PENDING' });
224
+ }
225
+ return t;
226
+ }
227
+ function taskStatus(t) { return RM.taskStatus(shownTask(t)); }
228
+ function attemptLamps(t) {
229
+ const shown = shownTask(t);
230
+ const rows = list(t.attempts).map(a => RM.attemptStatus(shown, a.attempt, a.status));
231
+ if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
232
+ return rows;
220
233
  }
221
234
  function taskLine(el) {
222
235
  const t = el.data.task, s = el.status;
@@ -256,6 +269,7 @@ export async function createStage(host, cb = {}) {
256
269
  const tries = Math.max(1, list(t.attempts).length);
257
270
  leds(el, Math.min(20, tries), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
258
271
  el.ledCount = tries;
272
+ setLeds(el, attemptLamps(t).slice(-el.ledCount));
259
273
  display(el, taskLine, { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
260
274
  // the Clef ring: how likely the first try was to pass, in the colour of what happened
261
275
  const p0 = t.clef && t.clef.first_pass && num(t.clef.first_pass.p);
@@ -276,9 +290,12 @@ export async function createStage(host, cb = {}) {
276
290
  const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
277
291
  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' };
278
292
  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' };
293
+ const status = finishStatus(id, { status: F.final.status });
294
+ const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
279
295
  const f = F.final.findings;
280
- return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status: F.final.status, findings: f,
281
- line: F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : F.final.status };
296
+ const line = status === 'OUTDATED' ? RM.reviewSinceLine(src)
297
+ : F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : status;
298
+ return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status, findings: f, line };
282
299
  }
283
300
  function buildFinish(m, p, g) {
284
301
  const [x, y, w, d, h] = p;
@@ -308,7 +325,7 @@ export async function createStage(host, cb = {}) {
308
325
  }
309
326
  function finishLeds(el) {
310
327
  if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
311
- else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
328
+ else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
312
329
  else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
313
330
  }
314
331
  function buildPlan(g) {
@@ -954,7 +971,7 @@ export async function createStage(host, cb = {}) {
954
971
  if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
955
972
  if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * (E.theme === 'light' ? 1.45 : 1.1) + .08 * c.sel) * ob * gf * c.vis; }
956
973
  if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
957
- if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
974
+ if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS', 'ACCEPTED'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
958
975
  if (el.inner) {
959
976
  const p = pr ? pr.p : 0, vis = p > .002;
960
977
  if (!vis && el.inner.visible) { E.release(el.inner); }
@@ -1239,6 +1256,7 @@ export async function createStage(host, cb = {}) {
1239
1256
  const lamps = { title: 'Lamps', rows: [
1240
1257
  { sw: { lamp: 'st-merged' }, label: 'merged · passed · accepted' }, { sw: { lamp: 'st-running' }, label: 'running · interrupted' },
1241
1258
  { sw: { lamp: 'st-fail' }, label: 'failed · conflict · infra · timeout' }, { sw: { lamp: 'st-pending' }, label: 'pending, not started' },
1259
+ { sw: { lamp: 'st-outdated' }, label: 'outdated review' },
1242
1260
  { sw: { route: true }, label: st.view === 'atlas' ? 'chosen run' : 'chosen task' + (L.route ? ' and its route' : '') }] };
1243
1261
  if (st.view === 'atlas') {
1244
1262
  out.push({ title: 'Map', rows: [], note: 'Each block is a run, each district a repository. The lamps on a block are its tasks; the block stands taller the more tasks it has.' }, lamps);
@@ -1318,8 +1336,7 @@ export async function createStage(host, cb = {}) {
1318
1336
  if (el.kind === 'task') {
1319
1337
  const t = byId.get(el.data.task.id); if (!t) continue;
1320
1338
  el.data.task = t; setStatus(el, taskStatus(t)); halo(el);
1321
- const rows = list(t.attempts).map(a => a.status);
1322
- if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
1339
+ const rows = attemptLamps(t);
1323
1340
  if (rows.length > el.ledCount) { /* more tries than lamps: the newest show */ }
1324
1341
  setLeds(el, rows.slice(-el.ledCount));
1325
1342
  const sig = JSON.stringify([t.status, t.findings, t.gate, list(t.shadow).map(s => s.agreement), t.dwarf, t.accepted_by, el.status]);
@@ -1385,7 +1402,7 @@ export async function createStage(host, cb = {}) {
1385
1402
  E.invalidate(100); // marks a replay reaches drop in
1386
1403
  st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
1387
1404
  for (const el of EL.values()) {
1388
- if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
1405
+ if (el.kind === 'task') { setStatus(el, taskStatus(el.data.task)); setLeds(el, attemptLamps(el.data.task).slice(-el.ledCount)); }
1389
1406
  else if (el.kind === 'finish') setStatus(el, finishStatus(el.id, finishInfo(el.id)));
1390
1407
  else if (el.data && el.data.item) setStatus(el, st.replay !== null ? replayItemStatus(el.data.item) : el.data.item.status);
1391
1408
  else continue;
@@ -85,13 +85,25 @@
85
85
  return { editor: { base: fresh, draft: e.draft }, dirty: true, moved: norm(fresh) !== norm(e.base) };
86
86
  }
87
87
  const edited = (editor, draft) => ({ base: editor.base, draft: text(draft) });
88
+ // The editor after typing in a textarea drawn from the file text `from`. When a read moved `base` on since the textarea
89
+ // was drawn (a held body is not redrawn), the typing sits on the old text, so the save would overwrite the new file.
90
+ function typed(editor, draft, from) {
91
+ const e = edited(editor, draft), dirty = isDirty(e);
92
+ return { editor: e, dirty, moved: dirty && norm(from) !== norm(e.base) };
93
+ }
88
94
  const shown = editor => editor.draft !== null ? editor.draft : editor.base;
95
+ // What a failed GET /api/config does: with a good read of this run on the page, it stays and a note names the
96
+ // failure; with none yet, the message replaces the page. Status 0 is no answer at all.
97
+ function readFailure(res, hasData) {
98
+ const message = (res && res.status !== 0 && (res.detail || res.error)) || 'could not reach forge-ui';
99
+ return { keep: !!hasData, message };
100
+ }
89
101
 
90
102
  /* ---------- DOM ---------- */
91
103
  function render(host, ctx) {
92
104
  const UI = root.ForgeUI, c = ctx || {};
93
105
  const el = (tag, t, cls) => UI.el(tag, t, cls);
94
- const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false };
106
+ const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false, asking: 0, asks: 0, pickerLate: false };
95
107
  const key_ = (label, cls, onclick) => { const b = el('button', label, cls); b.type = 'button'; if (onclick) b.addEventListener('click', onclick); return b; };
96
108
 
97
109
  const head = el('header', undefined, 'set-head');
@@ -101,10 +113,17 @@
101
113
  const body = el('div', undefined, 'set-body');
102
114
  const note = el('p', 'This page is read-only: this server has no write token, or has controls off.', 'muted set-readonly');
103
115
  note.hidden = true;
104
- host.replaceChildren(head, picker, note, body);
116
+ // A failed read over a good one: the page stays, and this says the shown values may be old. Outside the body, so a
117
+ // held body (an open confirm) still gets it.
118
+ const stale = el('p', undefined, 'warn set-problem set-stale'); stale.setAttribute('role', 'status');
119
+ stale.hidden = true;
120
+ host.replaceChildren(head, picker, note, stale, body);
121
+ const staleNote = msg => { stale.hidden = !msg; stale.textContent = msg ? `The last read of the configuration failed: ${msg}. This shows the read before it; the next refresh tries again.` : ''; };
105
122
 
106
123
  /* the repository picker */
107
124
  function drawPicker() {
125
+ // The switch confirm lives in the picker: while it is open the rebuild waits, and runs when it is answered.
126
+ if (s.asking) { s.pickerLate = true; return; }
108
127
  const runs = c.runs ? c.runs() : [];
109
128
  const groups = groupRuns(runs);
110
129
  const before = s.run, usable = withRepo(runs);
@@ -138,7 +157,14 @@
138
157
  }
139
158
  sel.addEventListener('change', async () => {
140
159
  const unsaved = FILES.filter(n => s.editors[n] && isDirty(s.editors[n]));
141
- if (unsaved.length && !(await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true }))) { sel.value = s.gone ? '' : s.run; return; }
160
+ if (unsaved.length) {
161
+ const ask = s.asking = ++s.asks;
162
+ const yes = await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true });
163
+ if (s.asking !== ask) return; // a later change asked again, and that confirm decides
164
+ const late = s.pickerLate;
165
+ s.asking = 0; s.pickerLate = false;
166
+ if (!yes) { sel.value = s.gone ? '' : s.run; if (late) drawPicker(); return; }
167
+ }
142
168
  pick(sel.value);
143
169
  });
144
170
  const lab = el('label', undefined, 'set-field'); lab.htmlFor = 'set-run';
@@ -151,11 +177,13 @@
151
177
  }
152
178
  // A new run: nothing of the old one stays, and confirms of the old body (now detached) no longer hold a redraw.
153
179
  function resetRun() {
180
+ staleNote(null);
154
181
  s.data = null; s.dataText = null; s.error = null; s.editors = {}; s.moved = {}; s.loading = s.run != null;
155
182
  s.gen++; s.hold = 0; s.redraw = false;
156
183
  }
157
184
  function pick(key) {
158
185
  s.run = key; resetRun();
186
+ s.asking = 0; s.pickerLate = false; // a switch confirm still open goes with the old picker
159
187
  s.runsSig = null; drawPicker(); drawBody();
160
188
  if (c.onPick) c.onPick(key);
161
189
  load();
@@ -171,12 +199,12 @@
171
199
  if (seq !== s.seq || run !== s.run) return; // a later pick or load answered
172
200
  s.loading = false;
173
201
  if (!res.ok) {
174
- const msg = res.status === 0 ? null : res.detail || res.error;
175
- if (msg === null && s.data) return; // a poll that did not reach the server keeps the last good page
202
+ const failed = readFailure(res, s.data);
203
+ if (failed.keep) { staleNote(failed.message); return; } // the last good page, its editors and their unsaved text stay
176
204
  if (s.hold > 0) return; // a confirm or a write is open: its strip must not be torn down
177
- s.error = msg || 'could not reach forge-ui'; s.data = null; s.dataText = null; drawBody(); return;
205
+ s.error = failed.message; s.data = null; s.dataText = null; drawBody(); return;
178
206
  }
179
- s.error = null;
207
+ s.error = null; staleNote(null);
180
208
  const files = object(object(res.data.repo).files);
181
209
  for (const name of FILES) {
182
210
  const next = dirtyState(s.editors[name], object(files[name]).content);
@@ -305,7 +333,9 @@
305
333
  const top = el('div', undefined, 'set-file-head'); top.append(h, status);
306
334
  art.append(top, el('p', blurb, 'muted set-file-blurb'));
307
335
  if (file.note) art.append(el('p', text(file.note), 'warn set-file-note'));
308
- if (isDirty(ed) && s.moved[name]) art.append(el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note'));
336
+ const movedNote = el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note');
337
+ movedNote.hidden = !(isDirty(ed) && s.moved[name]); // sync() keeps it current as the text is typed
338
+ art.append(movedNote);
309
339
  if (!s.writes) {
310
340
  status.replaceChildren(el('i', undefined, 'lamp ' + (file.exists ? 'st-pass' : '')), el('span', file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
311
341
  art.append(unreadable ? el('p', file.exists ? 'The content is not shown.' : 'Not set.', 'muted') : el('pre', text(file.content), 'set-pre'));
@@ -316,6 +346,7 @@
316
346
  ta.dataset.editor = name; ta.rows = Math.max(3, Math.min(14, shown(ed).split('\n').length + 1));
317
347
  ta.spellcheck = false; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocomplete', 'off');
318
348
  ta.value = shown(ed);
349
+ let from = ed.base; // the file text this textarea was drawn from
319
350
  if (unreadable && file.exists) { ta.disabled = true; ta.placeholder = 'The content is not shown, so it cannot be edited here.'; }
320
351
  const problem = el('p', undefined, 'warn set-problem'); problem.hidden = true; problem.id = id + '-problem';
321
352
  const result = el('div', undefined, 'set-result');
@@ -328,10 +359,15 @@
328
359
  if (bad) { ta.setAttribute('aria-invalid', 'true'); ta.setAttribute('aria-describedby', problem.id); }
329
360
  else { ta.removeAttribute('aria-invalid'); ta.removeAttribute('aria-describedby'); }
330
361
  save.disabled = !dirtyNow || !!bad;
362
+ movedNote.hidden = !(dirtyNow && s.moved[name]);
331
363
  status.replaceChildren(el('i', undefined, 'lamp ' + (dirtyNow ? 'set-amber' : file.exists ? 'st-pass' : '')),
332
364
  el('span', dirtyNow ? 'unsaved edits' : file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
333
365
  };
334
- ta.addEventListener('input', () => { s.editors[name] = edited(s.editors[name], ta.value); sync(); });
366
+ ta.addEventListener('input', () => {
367
+ const next = typed(s.editors[name], ta.value, from);
368
+ s.editors[name] = next.editor; s.moved[name] = next.moved || (next.dirty && !!s.moved[name]);
369
+ sync();
370
+ });
335
371
  remove.disabled = !file.exists;
336
372
  save.addEventListener('click', async () => {
337
373
  const cur = s.editors[name], bad = valueProblem(name, cur.draft);
@@ -342,7 +378,7 @@
342
378
  if (!yes) { ta.readOnly = false; remove.disabled = !file.exists; release(); sync(); save.focus({ preventScroll: true }); return; }
343
379
  const res = await UI.post('/api/config', configBody(s.run, repoKey(name), cur.draft));
344
380
  UI.outcome(result, res, `Saved ${target}.`);
345
- if (res.ok && gen === s.gen) s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n');
381
+ if (res.ok && gen === s.gen) { s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n'); s.moved[name] = false; from = s.editors[name].base; }
346
382
  ta.readOnly = false; remove.disabled = !file.exists; release(); sync();
347
383
  if (res.ok && gen === s.gen) await reload();
348
384
  });
@@ -353,7 +389,7 @@
353
389
  if (!yes) { ta.readOnly = false; release(); sync(); remove.disabled = !file.exists; remove.focus({ preventScroll: true }); return; }
354
390
  const res = await UI.post('/api/config', configBody(s.run, repoKey(name), null, true));
355
391
  UI.outcome(result, res, `Removed ${target}.`);
356
- if (res.ok && gen === s.gen) s.editors[name] = editorOf('');
392
+ if (res.ok && gen === s.gen) { s.editors[name] = editorOf(''); s.moved[name] = false; from = ''; }
357
393
  ta.readOnly = false; release();
358
394
  if (res.ok && gen === s.gen) await reload(); else if (!res.ok) { remove.disabled = !file.exists; sync(); }
359
395
  });
@@ -395,5 +431,5 @@
395
431
  }
396
432
 
397
433
  return { render, configBody, repoKey, targetPath, subsetOk, valueProblem, pickDefault, groupRuns, optionLabel,
398
- editorOf, isDirty, dirtyState, edited, shown, FILES, VALUE_MAX };
434
+ editorOf, isDirty, dirtyState, edited, typed, shown, readFailure, FILES, VALUE_MAX };
399
435
  });
@@ -91,13 +91,14 @@
91
91
  :root[data-theme=dark] { color-scheme: dark; }
92
92
 
93
93
  /* status: the lamp behind each forge state. Five lamps, one meaning each (--st; --st-lit on the 3D plate);
94
- a hollow lamp is that lamp's family but not its plain case: passed without a merge, stopped, failed outside the code */
94
+ a hollow lamp is that lamp's family but not its plain case: passed without a merge, stopped, failed outside the code,
95
+ an outdated review */
95
96
  .st-merged, .st-pass, .st-accepted, .st-nochanges { --st: var(--lamp-green); --st-lit: var(--lit-green); }
96
97
  .st-running, .st-interrupted { --st: var(--lamp-amber); --st-lit: var(--lit-amber); }
97
- .st-pending, .st-none, .st-unknown, .st-invalidated { --st: var(--lamp-off); --st-lit: var(--lamp-off); }
98
+ .st-pending, .st-none, .st-unknown, .st-invalidated, .st-outdated { --st: var(--lamp-off); --st-lit: var(--lamp-off); }
98
99
  .st-fail, .st-error, .st-conflict, .st-infra, .st-timeout, .st-blocked { --st: var(--lamp-red); --st-lit: var(--lit-red); }
99
100
  [class^="st-"], [class*=" st-"] { --st-fill: var(--st); --st-ring: var(--lamp-edge); }
100
- .st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked { --st-fill: transparent; --st-ring: var(--st); }
101
+ .st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked, .st-outdated { --st-fill: transparent; --st-ring: var(--st); }
101
102
  /* review severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin) on a ramp of its own, never a status lamp */
102
103
  .sev-P0 { --sev: var(--sev-p0); --sev-shape: polygon(50% 0, 100% 100%, 0 100%); }
103
104
  .sev-P1 { --sev: var(--sev-p1); --sev-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
@@ -152,6 +153,7 @@ kbd { font-family: var(--mono); font-size: var(--t-xs); padding: 0 5px; border:
152
153
  .chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 7px 0 6px; border: 1px solid var(--rule); border-radius: var(--r-small); background: var(--key); color: var(--ink); white-space: nowrap;
153
154
  font-family: var(--font-plate); font-weight: 700; font-size: .857rem; letter-spacing: .05em; text-transform: uppercase; line-height: 1.5; font-variant-numeric: tabular-nums; }
154
155
  .chip::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--st-fill, var(--lamp-off)); box-shadow: inset 0 0 0 1.5px var(--st-ring, transparent); flex: none; }
156
+ a.chip, a.chip:hover { color: inherit; text-decoration: none; }
155
157
  .chips { display: flex; gap: var(--s-1); flex-wrap: wrap; }
156
158
  .sev-chip { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: var(--t-xs); white-space: nowrap; }
157
159
  .sev-chip::before { content: ''; width: 9px; height: 9px; clip-path: var(--sev-shape, circle(30%)); background: var(--sev, var(--muted)); flex: none; }
@@ -683,6 +685,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
683
685
  .fg-bar { fill: var(--st, var(--lamp-off)); }
684
686
  .fg-verdict { stroke: var(--lamp-red); }
685
687
  .fg-verdict.fg-pass { stroke: var(--st, var(--lamp-green)); }
688
+ .fg-node.st-accepted .fg-box { fill: transparent; }
686
689
  .fg-empty { color: var(--muted); padding: var(--s-3) 0; }
687
690
  .gantt, .strip { display: block; font-family: var(--mono); }
688
691
  .gt-lane { fill: var(--module-2); }
@@ -693,6 +696,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
693
696
  .gt-tick-label { fill: var(--muted); font-size: 10px; }
694
697
  .gt-item { cursor: pointer; }
695
698
  .gt-box { fill: var(--st, var(--lamp-off)); fill-opacity: .2; stroke: var(--st, var(--lamp-off)); stroke-width: 1.2; }
699
+ .gt-item.st-accepted .gt-box { fill: transparent; }
696
700
  .gt-item:hover .gt-box, .gt-item:focus .gt-box { fill-opacity: .35; }
697
701
  .gt-item:focus { outline: none; }
698
702
  .gt-item.gt-sel .gt-box { stroke: var(--route); stroke-width: 2.5; }