@bongos/core 1.17.2 → 1.17.3

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 (77) hide show
  1. package/.bongos-core.json +292 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +65 -26
  8. package/docs/api-reference.md +3 -2
  9. package/modules/hall-ui/public/builders.js +96 -646
  10. package/modules/hall-ui/public/goal-inbox.js +67 -26
  11. package/modules/hall-ui/public/goals-page.js +4 -1
  12. package/modules/hall-ui/public/goals.html +8 -0
  13. package/modules/hall-ui/public/hall-kit.js +474 -0
  14. package/modules/hall-ui/public/hall-widgets.js +11 -15
  15. package/modules/hall-ui/public/instances-panel.js +24 -3
  16. package/modules/hall-ui/public/new-project-wizard.js +6 -5
  17. package/modules/hall-ui/public/style.css +125 -0
  18. package/modules/hall-ui/public/work.css +194 -0
  19. package/modules/hall-ui/public/work.html +46 -374
  20. package/modules/hall-ui/public/work.js +137 -102
  21. package/modules/lifecycle/criterion-suggest.js +80 -0
  22. package/modules/lifecycle/db.js +42 -5
  23. package/modules/lifecycle/routes/goals.js +26 -0
  24. package/modules/lifecycle/routes/tasks.js +21 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/hall-preview/README.md +103 -0
  28. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  29. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  30. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  31. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  32. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  33. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  34. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  35. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  36. package/scripts/hall-preview/fixtures/goals.json +502 -0
  37. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  38. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  39. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  40. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  41. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  42. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  43. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  44. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  45. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  46. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  47. package/scripts/hall-preview/fixtures/instance.json +124 -0
  48. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  49. package/scripts/hall-preview/fixtures/me.json +476 -0
  50. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  51. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  52. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  53. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  54. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  55. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  56. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  57. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  58. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  59. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  60. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  61. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  62. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  63. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  64. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  65. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  66. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  67. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  68. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  69. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  70. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  71. package/scripts/hall-preview/fixtures/versions.json +89 -0
  72. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  73. package/scripts/hall-preview/server.js +178 -0
  74. package/scripts/migrate.sh +29 -4
  75. package/src/bongos/optimizer.js +37 -2
  76. package/tests/criterion_suggest.mjs +106 -0
  77. package/tests/migrate_root_resolution.mjs +97 -0
@@ -335,16 +335,42 @@
335
335
  // element ids the data-render + event-wiring code expects (#welcome-minimize,
336
336
  // #recent-ships, #lb-prev, …) are present. Each widget mount is
337
337
  // failure-isolated so one bad widget can't blank the hall.
338
+ // task 2169: zone budgets — Home cannot grow unbounded again. Caps count
339
+ // REGISTERED widgets per Home zone (phase/data gating of visibility stays
340
+ // each widget's own business). A widget past its zone's budget mounts into a
341
+ // hidden overflow container and surfaces as a one-line link in a "More" row
342
+ // at the end of home-main — clicking reveals that one section in place.
343
+ // The budgets themselves become a CI fitness assertion in task 2177.
344
+ const ZONE_BUDGETS = { 'home-stats': 1, 'home-band': 2, 'home-main': 3 };
345
+
338
346
  function mountHallWidgets() {
339
347
  const main = document.querySelector('main');
340
348
  if (!main || typeof window.OTB.hallWidgets !== 'function') return;
349
+ const zoneCounts = {};
350
+ const overflow = [];
351
+ let overflowHost = null;
341
352
  for (const wgt of window.OTB.hallWidgets()) {
342
353
  try {
354
+ let host = widgetHost(wgt) || main;
355
+ const isHomeZone = (wgt.view || 'home') === 'home' && wgt.zone && ZONE_BUDGETS[wgt.zone] != null;
356
+ if (isHomeZone) {
357
+ zoneCounts[wgt.zone] = (zoneCounts[wgt.zone] || 0) + 1;
358
+ if (zoneCounts[wgt.zone] > ZONE_BUDGETS[wgt.zone]) {
359
+ if (!overflowHost) {
360
+ overflowHost = document.createElement('div');
361
+ overflowHost.id = 'zone-home-overflow';
362
+ overflowHost.hidden = true;
363
+ document.getElementById('zone-home-main')?.appendChild(overflowHost);
364
+ }
365
+ host = overflowHost || host;
366
+ overflow.push({ id: wgt.id, label: wgt.label });
367
+ }
368
+ }
343
369
  let section = document.getElementById(wgt.id);
344
370
  if (!section) {
345
371
  section = document.createElement('section');
346
372
  section.id = wgt.id;
347
- (widgetHost(wgt) || main).appendChild(section);
373
+ host.appendChild(section);
348
374
  }
349
375
  section.className = wgt.sectionClass || 'scroll';
350
376
  if (wgt.labelledby) section.setAttribute('aria-labelledby', wgt.labelledby);
@@ -357,6 +383,26 @@
357
383
  console.error(`[builders] hall widget "${wgt && wgt.id}" failed to mount`, err);
358
384
  }
359
385
  }
386
+ if (overflow.length) {
387
+ const more = document.createElement('section');
388
+ more.className = 'scroll scroll--bare';
389
+ more.id = 'home-more-row';
390
+ more.innerHTML = `<p class="home-more">More: ${overflow.map((o) =>
391
+ `<a href="#" data-reveal="${o.id}">${o.label}</a>`).join(' · ')}</p>`;
392
+ document.getElementById('zone-home-main')?.appendChild(more);
393
+ more.addEventListener('click', (e) => {
394
+ const a = e.target.closest('[data-reveal]');
395
+ if (!a) return;
396
+ e.preventDefault();
397
+ const section = document.getElementById(a.dataset.reveal);
398
+ if (section) {
399
+ document.getElementById('zone-home-main')?.insertBefore(section, more);
400
+ section.hidden = false;
401
+ }
402
+ a.remove();
403
+ if (!more.querySelector('[data-reveal]')) more.remove();
404
+ });
405
+ }
360
406
  // brand-apply.js's currency-label pass ran at DOMContentLoaded, BEFORE these
361
407
  // skeletons existed — re-run it over the mounted widgets so [data-currency-
362
408
  // label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
@@ -889,7 +935,20 @@
889
935
  try { window.localStorage.setItem(SETUP_CONFIG_KEY, v === 'online' ? 'online' : 'desktop'); } catch (_) { /* private mode — won't persist, fine */ }
890
936
  }
891
937
 
938
+ // task 2169 (banner budget): exactly one banner panel at a time. The
939
+ // checklist render runs before the welcome render in the boot pass, so after
940
+ // the welcome panel's visibility settles we re-evaluate the checklist —
941
+ // welcome visible → checklist waits; welcome dismissed/hidden → checklist
942
+ // takes the banner slot (renderOnboarding carries the mirror check).
892
943
  function renderWelcome(builder, onboarding) {
944
+ renderWelcomeCore(builder, onboarding);
945
+ renderOnboarding(
946
+ onboarding || (lastMeState && lastMeState.onboarding) || null,
947
+ lastMeState && lastMeState.discord
948
+ );
949
+ }
950
+
951
+ function renderWelcomeCore(builder, onboarding) {
893
952
  const scroll = $('#welcome-scroll');
894
953
  const body = $('#welcome-body');
895
954
  if (!scroll || !body) return;
@@ -1080,6 +1139,15 @@
1080
1139
  scroll.hidden = true;
1081
1140
  return;
1082
1141
  }
1142
+ // task 2169 (banner budget: one at a time): while the welcome panel is
1143
+ // showing, the checklist waits its turn — it appears when the welcome is
1144
+ // dismissed or outgrown. renderWelcome runs first in the same loadAll pass
1145
+ // (and every SSE nudge re-runs both), so this stays current.
1146
+ const welcomeEl = document.getElementById('welcome-scroll');
1147
+ if (welcomeEl && !welcomeEl.hidden) {
1148
+ scroll.hidden = true;
1149
+ return;
1150
+ }
1083
1151
  scroll.hidden = false;
1084
1152
 
1085
1153
  const discordConfigured = !!(discord && discord.configured);
@@ -1784,41 +1852,38 @@
1784
1852
 
1785
1853
  // ---- idea-inbox triage cue (task 1471) -----------------------------------
1786
1854
 
1787
- // Render the Metic+ triage widget: the open-idea count + a few recent titles,
1788
- // each deep-linking to the #/idea/N overlay (which carries the Metic+ Discard
1789
- // control). Only ever called for a Metic+ viewer (loadAll gates the fetch), so
1790
- // reaching here is what un-hides the section — a non-triager never loads or
1791
- // sees any idea data. Fully failure-isolated by its caller.
1855
+ // task 2169: renders the IDEAS slice of the "Needs your attention" card —
1856
+ // goal-inbox.js owns the card (id 'attention-scroll') + the goals slice;
1857
+ // visibility and the headline count derive from both via OTBAttention.
1858
+ // Only ever called for a Metic+ viewer (loadAll gates the fetch) — a
1859
+ // non-triager never loads or sees any idea data. An empty inbox renders
1860
+ // nothing: the attention card never shows an "all clear" row.
1792
1861
  function renderTriageInbox(data) {
1793
- const section = document.getElementById('triage-inbox');
1794
- if (!section) return;
1862
+ const box = document.getElementById('attention-ideas');
1863
+ if (!box) return;
1795
1864
  const open = (data && Array.isArray(data.ideas) ? data.ideas : [])
1796
1865
  .filter((i) => i && i.status === 'open');
1797
1866
  const openCount = data && data.open_count != null ? data.open_count : open.length;
1798
-
1799
- const countEl = document.getElementById('triage-inbox-count');
1800
- if (countEl) {
1801
- countEl.textContent = `${fmtNum(openCount)} open`;
1802
- countEl.hidden = false;
1803
- }
1804
- const listEl = document.getElementById('triage-inbox-list');
1805
- if (listEl) {
1806
- if (!open.length) {
1807
- listEl.innerHTML = '<li class="triage-inbox__empty">Inbox clear — nothing awaiting triage.</li>';
1808
- } else {
1809
- listEl.innerHTML = open.slice(0, 8).map((i) => {
1810
- const title = escapeHtml(i.title || `idea #${i.id}`);
1811
- const kind = i.kind
1812
- ? `<span class="triage-inbox__kind">${escapeHtml(i.kind)}</span>`
1813
- : '';
1814
- return `<li class="triage-inbox__item">`
1815
- + `<a class="triage-inbox__link" href="#/idea/${encodeURIComponent(i.id)}">${title}</a>`
1816
- + kind
1817
- + `</li>`;
1818
- }).join('');
1819
- }
1867
+ if (!open.length) {
1868
+ box.innerHTML = '';
1869
+ } else {
1870
+ const rows = open.slice(0, 5).map((i) => {
1871
+ const title = escapeHtml(i.title || `idea #${i.id}`);
1872
+ const kind = i.kind
1873
+ ? `<span class="triage-inbox__kind">${escapeHtml(i.kind)}</span>`
1874
+ : '';
1875
+ return `<li class="triage-inbox__item">`
1876
+ + `<a class="triage-inbox__link" href="#/idea/${encodeURIComponent(i.id)}">${title}</a>`
1877
+ + kind
1878
+ + `</li>`;
1879
+ }).join('');
1880
+ const more = openCount > 5
1881
+ ? `<p class="ginbox__pending">…${fmtNum(openCount - 5)} more open ideas — open any row to triage in place.</p>`
1882
+ : '';
1883
+ box.innerHTML = `<h3 class="ginbox__sec-h">Ideas awaiting triage <span class="count-pill">${fmtNum(openCount)}</span></h3>`
1884
+ + `<ul class="triage-inbox">${rows}</ul>${more}`;
1820
1885
  }
1821
- section.hidden = false;
1886
+ window.OTBAttention?.refresh();
1822
1887
  }
1823
1888
 
1824
1889
  // ---- leaderboard ---------------------------------------------------------
@@ -2026,525 +2091,6 @@
2026
2091
  renderLeaderboard();
2027
2092
  }
2028
2093
 
2029
- // ---- Archon security watch (V3.R66 / #285) -------------------------------
2030
- //
2031
- // Hidden by default; revealed only when /me returns rank='archon'. The
2032
- // table renders open vulnerability reports + adjudication actions
2033
- // (confirm / dispute / fix / link-fix). The audit log is collapsed
2034
- // behind a <details> and loads on first open.
2035
-
2036
- const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
2037
-
2038
- let securityState = {
2039
- statusFilter: '',
2040
- auditLoaded: false,
2041
- };
2042
-
2043
- function severityBadge(sev) {
2044
- const cls = `sev-${sev}`;
2045
- return `<span class="sev-badge ${cls}">${escapeHtml(sev || '?')}</span>`;
2046
- }
2047
-
2048
- function renderSecurityReports(payload) {
2049
- const tbody = document.querySelector('#security-reports tbody');
2050
- if (!tbody) return;
2051
- const reports = Array.isArray(payload?.reports) ? payload.reports : [];
2052
- const bounty = payload?.bounty_table || {};
2053
- if (reports.length === 0) {
2054
- tbody.innerHTML = emptyRow(7, 'No reports match this filter.');
2055
- return;
2056
- }
2057
- // Sort: open first (by severity desc), then everything else newest first.
2058
- const sorted = reports.slice().sort((a, b) => {
2059
- const aOpen = a.status === 'open' || a.status === 'repro_attempted' ? 1 : 0;
2060
- const bOpen = b.status === 'open' || b.status === 'repro_attempted' ? 1 : 0;
2061
- if (aOpen !== bOpen) return bOpen - aOpen;
2062
- const aS = SEVERITY_RANK[a.severity] || 0;
2063
- const bS = SEVERITY_RANK[b.severity] || 0;
2064
- if (aS !== bS) return bS - aS;
2065
- return new Date(b.reported_at) - new Date(a.reported_at);
2066
- });
2067
- tbody.innerHTML = sorted.map((r) => {
2068
- const reporter = r.reporter_builder_id ? `#${escapeHtml(String(r.reporter_builder_id))}` : '—';
2069
- const drachmae = r.drachmae_awarded != null ? fmtNum(r.drachmae_awarded) : (bounty[r.severity] ? `(${fmtNum(bounty[r.severity])} if confirmed)` : '—');
2070
- const actions = renderSecurityActions(r);
2071
- return `
2072
- <tr data-report-id="${r.id}">
2073
- <td class="ledger__num">${escapeHtml(String(r.id))}</td>
2074
- <td>${severityBadge(r.severity)}</td>
2075
- <td>${escapeHtml(r.title || '')}<div style="font-size:0.78rem;opacity:0.7;">${escapeHtml(r.target || '')}</div></td>
2076
- <td>${escapeHtml(r.status || '')}</td>
2077
- <td>${reporter}</td>
2078
- <td class="ledger__drachmae">${drachmae}</td>
2079
- <td>${actions}</td>
2080
- </tr>
2081
- `;
2082
- }).join('');
2083
- }
2084
-
2085
- function renderSecurityActions(report) {
2086
- const id = report.id;
2087
- const isTerminal = report.status === 'fixed' || report.status === 'wontfix';
2088
- const buttons = [];
2089
- if (!isTerminal && report.status !== 'confirmed') {
2090
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="confirm" data-id="${id}">Confirm</button>`);
2091
- }
2092
- if (!isTerminal && report.status !== 'disputed') {
2093
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="dispute" data-id="${id}">Dispute</button>`);
2094
- }
2095
- if (report.status !== 'fixed') {
2096
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="fix" data-id="${id}">Mark fixed</button>`);
2097
- }
2098
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="link-fix" data-id="${id}" title="Attach a fix-task ID">Link fix…</button>`);
2099
- return buttons.join(' ');
2100
- }
2101
-
2102
- async function loadSecurityReports() {
2103
- const status = securityState.statusFilter;
2104
- const url = status ? `${API}/security/reports?status=${encodeURIComponent(status)}` : `${API}/security/reports`;
2105
- try {
2106
- const payload = await getJSON(url);
2107
- renderSecurityReports(payload);
2108
- } catch (err) {
2109
- if (err && err.message === 'redirecting to auth') return;
2110
- const tbody = document.querySelector('#security-reports tbody');
2111
- if (tbody) tbody.innerHTML = emptyRow(7, 'Could not load reports.');
2112
- console.error('[builders] load security reports failed', err);
2113
- }
2114
- }
2115
-
2116
- async function adjudicateReport(id, action) {
2117
- const status = $('#security-status');
2118
- if (status) { status.hidden = false; status.textContent = 'Working…'; }
2119
- try {
2120
- let body = {};
2121
- let path = `${API}/security/reports/${encodeURIComponent(id)}/${action}`;
2122
- if (action === 'link-fix') {
2123
- const taskIdRaw = window.prompt(`Task ID to link as the fix for report #${id}?`, '');
2124
- if (taskIdRaw == null) {
2125
- if (status) status.textContent = 'Cancelled.';
2126
- return;
2127
- }
2128
- const taskId = Number(taskIdRaw);
2129
- if (!Number.isInteger(taskId) || taskId <= 0) {
2130
- if (status) status.textContent = 'Bad task id — must be a positive integer.';
2131
- return;
2132
- }
2133
- body = { task_id: taskId };
2134
- }
2135
- const resp = await api.request('POST', path, { body });
2136
- if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); throw new Error('redirecting to auth'); }
2137
- const data = resp.data || {};
2138
- if (!resp.ok) {
2139
- if (status) status.textContent = `Failed: ${data.error || resp.status}`;
2140
- return;
2141
- }
2142
- if (status) status.textContent = `Report #${id} → ${data.report?.status || action}`;
2143
- await loadSecurityReports();
2144
- } catch (err) {
2145
- if (err && err.message === 'redirecting to auth') return;
2146
- console.error('[builders] adjudication failed', err);
2147
- if (status) status.textContent = 'Adjudication failed. Try again.';
2148
- }
2149
- }
2150
-
2151
- async function loadAuditLog() {
2152
- const tbody = document.querySelector('#security-audit-log tbody');
2153
- if (!tbody) return;
2154
- tbody.innerHTML = emptyRow(5, 'Loading…');
2155
- const method = $('#f-audit-method')?.value || '';
2156
- const builderId = $('#f-audit-builder')?.value || '';
2157
- const params = new URLSearchParams();
2158
- params.set('limit', '50');
2159
- if (method) params.set('method', method);
2160
- if (builderId) params.set('builder_id', builderId);
2161
- try {
2162
- const payload = await getJSON(`${API}/audit-log?${params}`);
2163
- const rows = Array.isArray(payload?.rows) ? payload.rows : [];
2164
- if (rows.length === 0) {
2165
- tbody.innerHTML = emptyRow(5, 'No matching audit entries.');
2166
- return;
2167
- }
2168
- tbody.innerHTML = rows.map((r) => `
2169
- <tr>
2170
- <td>${escapeHtml(fmtDate(r.recorded_at))}</td>
2171
- <td>#${escapeHtml(String(r.builder_id))}</td>
2172
- <td>${escapeHtml(r.method)}</td>
2173
- <td><code>${escapeHtml(r.route)}</code></td>
2174
- <td>${escapeHtml(String(r.response_status))}</td>
2175
- </tr>
2176
- `).join('');
2177
- } catch (err) {
2178
- if (err && err.message === 'redirecting to auth') return;
2179
- console.error('[builders] audit-log load failed', err);
2180
- tbody.innerHTML = emptyRow(5, 'Could not load audit log.');
2181
- }
2182
- }
2183
-
2184
- // task 1723: unhideArchonView is gone — the Archon nav group (Watch, Harbor,
2185
- // Gate, Sessions) is the shell's, revealed by renderNav's applyAccess call
2186
- // once /me confirms rank === 'archon' (shell.js gates Harbor on the dev-box
2187
- // module too). The data still loads only on the Archon pages, each behind its
2188
- // own archon-gated endpoint (#726).
2189
-
2190
- // ---- Archon roster + management (V3.R14 / #238) --------------------------
2191
- //
2192
- // Archon-only roster of every builder, with promote / demote /
2193
- // release-stale-claim controls. Hidden via #roster-scroll[hidden] for
2194
- // non-Archons; unhideArchonView flips it on.
2195
- //
2196
- // Rank ladder is xenos < thetes < metic < archon (RANK_ORDER above). Promote
2197
- // walks the ladder up ONE rung; demote walks it down one. The server enforces
2198
- // the one-rung-promotion rule (#707), so the button must offer the true next
2199
- // rung per row — a xenos promotes to thetes (not metic), a thetes to metic.
2200
-
2201
- const ROSTER_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
2202
- // #408: paginate the Archon roster — it can grow past a screenful as the
2203
- // community scales (200+ rows seen during stress test). 20 rows/page
2204
- // matches the cardinality of "one comfortable scroll" inside the section
2205
- // without forcing the user to scan further than they can hold in working
2206
- // memory. Filter dropdowns still scope the dataset *before* pagination,
2207
- // so a Show:onboarding filter still narrows to onboarding builders.
2208
- const ROSTER_PAGE_SIZE = 20;
2209
-
2210
- let rosterState = {
2211
- rows: [],
2212
- show: 'all',
2213
- staleHours: 24,
2214
- selfId: null,
2215
- loading: false,
2216
- page: 0,
2217
- };
2218
-
2219
- function nextRank(rank, direction /* 'up' | 'down' */) {
2220
- const idx = ROSTER_LADDER.indexOf((rank || '').toLowerCase());
2221
- if (idx < 0) return null;
2222
- if (direction === 'up' && idx < ROSTER_LADDER.length - 1) return ROSTER_LADDER[idx + 1];
2223
- if (direction === 'down' && idx > 0) return ROSTER_LADDER[idx - 1];
2224
- return null;
2225
- }
2226
-
2227
- function fmtTtfsMinutes(min) {
2228
- if (min === null || min === undefined) return '—';
2229
- const n = Number(min);
2230
- if (!Number.isFinite(n) || n < 0) return '—';
2231
- if (n < 60) return `${n}m`;
2232
- const hours = n / 60;
2233
- if (hours < 24) return `${hours.toFixed(1)}h`;
2234
- const days = hours / 24;
2235
- if (days < 30) return `${days.toFixed(1)}d`;
2236
- const months = days / 30;
2237
- return `${months.toFixed(1)}mo`;
2238
- }
2239
-
2240
- function rosterFilterRows(rows) {
2241
- switch (rosterState.show) {
2242
- case 'onboarding':
2243
- return rows.filter((r) => !r.first_ship_at && r.status !== 'inactive');
2244
- case 'active':
2245
- return rows.filter((r) => Number(r.active_claim_count) > 0);
2246
- case 'stale':
2247
- return rows.filter((r) => r.stale_claim);
2248
- case 'inactive':
2249
- return rows.filter((r) => r.status === 'inactive');
2250
- case 'all':
2251
- default:
2252
- return rows;
2253
- }
2254
- }
2255
-
2256
- function renderRoster() {
2257
- const tbody = document.querySelector('#roster-table tbody');
2258
- const countEl = $('#roster-count');
2259
- const pagerEl = $('#roster-pager');
2260
- const rangeEl = $('#roster-range');
2261
- const prevBtn = $('#roster-prev');
2262
- const nextBtn = $('#roster-next');
2263
- if (!tbody) return;
2264
- const rows = rosterFilterRows(rosterState.rows);
2265
- if (countEl) {
2266
- countEl.textContent = `${rows.length} / ${rosterState.rows.length}`;
2267
- }
2268
- if (rows.length === 0) {
2269
- tbody.innerHTML = emptyRow(9, 'No builders match this filter.');
2270
- if (pagerEl) pagerEl.hidden = true;
2271
- return;
2272
- }
2273
- // #408: client-side pagination. renderPager clamps the page index in case a
2274
- // filter/refresh shrank the dataset under our feet (e.g. on page 4, then
2275
- // switch to a filter with only 30 matching rows → snap back to the last
2276
- // legal page) and updates the range + prev/next controls.
2277
- const { start } = renderPager(rosterState, rows.length, ROSTER_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
2278
- const slice = rows.slice(start, start + ROSTER_PAGE_SIZE);
2279
-
2280
- tbody.innerHTML = slice.map((b) => {
2281
- const isSelf = String(b.id) === String(rosterState.selfId);
2282
- const isInactive = b.status === 'inactive';
2283
- const upRank = nextRank(b.rank, 'up');
2284
- const downRank = nextRank(b.rank, 'down');
2285
- const claimCount = Number(b.active_claim_count) || 0;
2286
- const stale = !!b.stale_claim;
2287
- const claimsCell = claimCount === 0
2288
- ? '<span style="opacity:0.6;">none</span>'
2289
- : `${claimCount}${stale ? ' <span class="roster__stale" title="At least one claim is stale (no pulse beyond threshold)">⚠ stale</span>' : ''}`;
2290
- const actions = [];
2291
- if (isSelf) {
2292
- actions.push('<span style="opacity:0.55;font-size:0.8rem;">— self —</span>');
2293
- } else if (isInactive) {
2294
- actions.push('<span style="opacity:0.55;font-size:0.8rem;">inactive</span>');
2295
- } else {
2296
- if (upRank) {
2297
- actions.push(`<button type="button" class="btn-ghost roster__promote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${upRank}">Promote → ${upRank}</button>`);
2298
- }
2299
- if (downRank) {
2300
- actions.push(`<button type="button" class="btn-ghost roster__demote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${downRank}">Demote → ${downRank}</button>`);
2301
- }
2302
- if (stale) {
2303
- actions.push(`<button type="button" class="btn-ghost roster__release-stale" data-id="${escapeHtml(String(b.id))}">Release stale claim</button>`);
2304
- }
2305
- }
2306
- const inactiveTag = isInactive ? ' <span class="roster__inactive">inactive</span>' : '';
2307
- const selfTag = isSelf ? ' <span class="roster__self">you</span>' : '';
2308
- const handle = b.display_name || b.github_login || `builder #${b.id}`;
2309
- return `
2310
- <tr data-builder-id="${escapeHtml(String(b.id))}">
2311
- <td>${escapeHtml(handle)}${selfTag}${inactiveTag}<div style="font-size:0.75rem;opacity:0.65;">#${escapeHtml(String(b.id))} · @${escapeHtml(b.github_login || '')}</div></td>
2312
- <td>${renderRankBadge(b.rank)}</td>
2313
- <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
2314
- <td>${fmtNum(b.karma)}</td>
2315
- <td class="ledger__date">${escapeHtml(fmtDate(b.onboarded_at))}</td>
2316
- <td class="ledger__date">${b.first_ship_at ? escapeHtml(fmtDate(b.first_ship_at)) : '<span style="opacity:0.6;">— not yet —</span>'}</td>
2317
- <td>${fmtTtfsMinutes(b.time_to_first_ship_minutes)}</td>
2318
- <td>${claimsCell}</td>
2319
- <td>${actions.join(' ')}</td>
2320
- </tr>
2321
- `;
2322
- }).join('');
2323
- }
2324
-
2325
- async function loadRoster() {
2326
- const status = $('#roster-status');
2327
- if (rosterState.loading) return;
2328
- rosterState.loading = true;
2329
- if (status) { status.hidden = false; status.textContent = 'Loading roster…'; }
2330
- try {
2331
- const payload = await getJSON(`${API}/builders/roster?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2332
- rosterState.rows = Array.isArray(payload?.builders) ? payload.builders : [];
2333
- renderRoster();
2334
- if (status) status.hidden = true;
2335
- } catch (err) {
2336
- if (err && err.message === 'redirecting to auth') return;
2337
- console.error('[builders] load roster failed', err);
2338
- const tbody = document.querySelector('#roster-table tbody');
2339
- if (tbody) tbody.innerHTML = emptyRow(9, 'Could not load the roster.');
2340
- if (status) { status.hidden = false; status.textContent = `Could not load roster: ${err.message}`; }
2341
- } finally {
2342
- rosterState.loading = false;
2343
- }
2344
- }
2345
-
2346
- async function rosterChangeRank(builderId, nextRankName, currentRankName) {
2347
- const status = $('#roster-status');
2348
- const ok = window.confirm(
2349
- `Change builder #${builderId} from ${currentRankName || '?'} to ${nextRankName}?`
2350
- );
2351
- if (!ok) return;
2352
- if (status) { status.hidden = false; status.textContent = `Setting rank to ${nextRankName}…`; }
2353
- try {
2354
- const resp = await patchJSON(`${API}/builders/${encodeURIComponent(builderId)}/rank`, { rank: nextRankName });
2355
- const newRank = resp?.builder?.rank || nextRankName;
2356
- if (status) status.textContent = `Builder #${builderId} → ${newRank}.`;
2357
- toast(`Builder #${builderId} is now ${newRank}.`);
2358
- await loadRoster();
2359
- } catch (err) {
2360
- if (err && err.message === 'redirecting to auth') return;
2361
- console.error('[builders] rank change failed', err);
2362
- if (status) { status.hidden = false; status.textContent = `Rank change failed: ${err.message}`; }
2363
- toast('Rank change failed.');
2364
- }
2365
- }
2366
-
2367
- async function rosterReleaseStale(builderId) {
2368
- const status = $('#roster-status');
2369
- let claims;
2370
- if (status) { status.hidden = false; status.textContent = 'Looking up stale claims…'; }
2371
- try {
2372
- const payload = await getJSON(`${API}/builders/${encodeURIComponent(builderId)}/active-claims?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2373
- claims = Array.isArray(payload?.claims) ? payload.claims : [];
2374
- } catch (err) {
2375
- if (err && err.message === 'redirecting to auth') return;
2376
- if (status) status.textContent = `Could not fetch claims: ${err.message}`;
2377
- return;
2378
- }
2379
- const stale = claims.filter((c) => c.stale_claim);
2380
- if (stale.length === 0) {
2381
- if (status) status.textContent = 'No stale claims left to release for that builder.';
2382
- // The roster row was probably stale-from-cache — refresh.
2383
- await loadRoster();
2384
- return;
2385
- }
2386
- // Pick the oldest stale claim (the queue order matches claimed_at ASC
2387
- // from the server). For multiple stale claims, surface the count so
2388
- // the Archon knows another action may be needed.
2389
- const target = stale[0];
2390
- const reason = window.prompt(
2391
- `Release stale claim #${target.claim_id} (task #${target.task_id} "${target.task_title || ''}") for builder #${builderId}?\n\nReason (required):`,
2392
- 'pulse heartbeat went silent; freeing the task'
2393
- );
2394
- if (reason == null) {
2395
- if (status) status.textContent = 'Cancelled.';
2396
- return;
2397
- }
2398
- const trimmed = String(reason).trim();
2399
- if (!trimmed) {
2400
- if (status) status.textContent = 'A reason is required for an on-behalf release.';
2401
- return;
2402
- }
2403
- if (status) status.textContent = `Releasing claim #${target.claim_id}…`;
2404
- try {
2405
- const resp = await api.request('POST', `${API}/claims/${encodeURIComponent(target.claim_id)}/release-on-behalf`, { body: { reason: trimmed } });
2406
- if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2407
- const data = resp.data || {};
2408
- if (!resp.ok) {
2409
- if (status) status.textContent = `Release failed: ${data.error || resp.status}`;
2410
- toast('Release failed.');
2411
- return;
2412
- }
2413
- const remaining = stale.length - 1;
2414
- if (status) status.textContent = `Released claim #${target.claim_id}.` + (remaining > 0 ? ` ${remaining} more stale claim(s) on this builder — click again to release the next.` : '');
2415
- toast('Claim released.');
2416
- await loadRoster();
2417
- } catch (err) {
2418
- if (status) status.textContent = `Release failed: ${err.message}`;
2419
- toast('Release failed.');
2420
- }
2421
- }
2422
-
2423
- // ---- override requests (V3.R74 / #293) -----------------------------------
2424
- //
2425
- // Archon-only. Lists open requests where a builder asked the Archon to
2426
- // override a failed grader. Approve flips task to confirmed + pays the
2427
- // requester; deny only stamps the decision row.
2428
-
2429
- async function loadOverrideRequests() {
2430
- const tbody = document.querySelector('#override-requests tbody');
2431
- if (!tbody) return;
2432
- try {
2433
- const data = await getJSON(`${API}/override-requests?status=open`);
2434
- const rows = Array.isArray(data?.requests) ? data.requests : [];
2435
- if (rows.length === 0) {
2436
- tbody.innerHTML = emptyRow(6, 'No open requests.');
2437
- return;
2438
- }
2439
- tbody.innerHTML = rows.map((r) => `
2440
- <tr data-request-id="${escapeHtml(String(r.id))}">
2441
- <td>${escapeHtml(String(r.id))}</td>
2442
- <td>#${escapeHtml(String(r.task_id))} — ${escapeHtml(r.task_title || '')}</td>
2443
- <td>@${escapeHtml(r.requester_login || '')}</td>
2444
- <td class="override__rationale">${escapeHtml(r.rationale || '')}</td>
2445
- <td>${escapeHtml(String(r.credits_reward ?? '—'))}</td>
2446
- <td>
2447
- <button class="btn-ghost override__approve" type="button">Approve</button>
2448
- <button class="btn-ghost override__deny" type="button">Deny</button>
2449
- </td>
2450
- </tr>
2451
- `).join('');
2452
- } catch (err) {
2453
- if (err && err.message === 'redirecting to auth') return;
2454
- console.error('[builders] override-requests failed', err);
2455
- tbody.innerHTML = emptyRow(6, 'Could not load override requests.');
2456
- }
2457
- }
2458
-
2459
- async function decideOverride(requestId, decision) {
2460
- const status = $('#override-status');
2461
- const verb = decision === 'approve' ? 'Approve' : 'Deny';
2462
- const note = window.prompt(`${verb} request #${requestId} — optional note (sent to audit log):`, '');
2463
- if (note === null) return; // user cancelled
2464
- try {
2465
- const res = await api.request('POST', `${API}/override-requests/${encodeURIComponent(requestId)}/decide`, { body: { decision, note: note || undefined } });
2466
- if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2467
- const body = res.data || {};
2468
- if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
2469
- if (status) {
2470
- status.hidden = false;
2471
- status.textContent = decision === 'approve'
2472
- ? `Approved — task flipped to confirmed, ${curLabel()} paid: ${body.confirmResult?.creditsAwarded ?? 0}.`
2473
- : `Denied — the task stays at completed.`;
2474
- }
2475
- await loadOverrideRequests();
2476
- } catch (err) {
2477
- console.error('[builders] decide override failed', err);
2478
- if (status) { status.hidden = false; status.textContent = `Could not decide: ${err.message}`; }
2479
- }
2480
- }
2481
-
2482
- // ---- skill model preferences (V3.R85 / #304) -----------------------------
2483
- //
2484
- // The matrix lets each builder override the system default model for each
2485
- // slash-command skill. System defaults come from the audit in
2486
- // src/bongos/skill-prefs.js (mechanism vs judgment classification) and are
2487
- // echoed back in the GET response so the table can label them.
2488
-
2489
- const MODEL_LABELS = {
2490
- opus: 'Opus',
2491
- sonnet: 'Sonnet',
2492
- haiku: 'Haiku',
2493
- script: 'no LLM',
2494
- };
2495
-
2496
- function modelLabel(m) {
2497
- return MODEL_LABELS[m] || m || '—';
2498
- }
2499
-
2500
- function renderSkillPrefs(data) {
2501
- const tbody = $('#skill-prefs-table tbody');
2502
- if (!tbody) return;
2503
- const rows = Array.isArray(data?.effective) ? data.effective : [];
2504
- const allowed = Array.isArray(data?.allowed_models) ? data.allowed_models : ['opus', 'sonnet', 'haiku', 'script'];
2505
- if (rows.length === 0) {
2506
- tbody.innerHTML = emptyRow(3, 'No skills registered.');
2507
- return;
2508
- }
2509
- tbody.innerHTML = rows.map((row) => {
2510
- const opts = ['<option value="">— default —</option>']
2511
- .concat(allowed.map((m) => {
2512
- const sel = row.override_model === m ? ' selected' : '';
2513
- return `<option value="${m}"${sel}>${modelLabel(m)}</option>`;
2514
- }))
2515
- .join('');
2516
- const defaultCell = `<span class="skill-prefs__default" title="System default for this skill">${modelLabel(row.default_model)}</span>`;
2517
- return `
2518
- <tr data-skill="${escapeHtml(row.skill)}">
2519
- <td><code>${escapeHtml(row.skill)}</code></td>
2520
- <td>${defaultCell}</td>
2521
- <td>
2522
- <select class="skill-prefs__select" data-skill="${escapeHtml(row.skill)}" aria-label="Model for ${escapeHtml(row.skill)}">
2523
- ${opts}
2524
- </select>
2525
- </td>
2526
- </tr>
2527
- `;
2528
- }).join('');
2529
- }
2530
-
2531
- async function saveSkillPref(skill, modelOrNull) {
2532
- const status = $('#skill-prefs-status');
2533
- if (status) { status.hidden = false; status.textContent = 'Saving…'; }
2534
- try {
2535
- const body = { [skill]: modelOrNull };
2536
- const resp = await patchJSON(`${API}/me/skill-prefs`, body);
2537
- renderSkillPrefs(resp);
2538
- if (status) status.textContent = modelOrNull
2539
- ? `Set ${skill} → ${modelLabel(modelOrNull)}.`
2540
- : `Cleared ${skill} override (falls back to default).`;
2541
- } catch (err) {
2542
- if (err && err.message === 'redirecting to auth') return;
2543
- console.error('[builders] save skill pref failed', err);
2544
- if (status) status.textContent = 'Could not save. Try again.';
2545
- }
2546
- }
2547
-
2548
2094
  // ---- onboarding live-tick poll (task 1444) -------------------------------
2549
2095
  //
2550
2096
  // The onboarding checklist is rendered once from the page-load /me snapshot.
@@ -2679,8 +2225,6 @@
2679
2225
  // /me because it spans all builders' work), loaded fire-and-forget so a
2680
2226
  // slow/failed feed can't hold up the rest of the hall.
2681
2227
  loadAcclaimFeed();
2682
- // Capture self id for the roster's "cannot change your own rank" guard.
2683
- rosterState.selfId = me.builder?.id ?? null;
2684
2228
 
2685
2229
  // #408: build the sticky nav now that every section has settled its
2686
2230
  // hidden state (welcome → karma → onboarding → security/roster).
@@ -2861,100 +2405,6 @@
2861
2405
  });
2862
2406
  }
2863
2407
 
2864
- // Security watch (Archon-only) — status filter + delegated action clicks.
2865
- // The section is hidden for non-Archons via unhideArchonView, so these
2866
- // listeners are safe to attach unconditionally (they just won't fire).
2867
- $('#f-security-status')?.addEventListener('change', (e) => {
2868
- securityState.statusFilter = e.target.value || '';
2869
- loadSecurityReports();
2870
- });
2871
- $('#security-reports')?.addEventListener('click', (e) => {
2872
- const btn = e.target.closest('.sec-act');
2873
- if (!btn) return;
2874
- const id = btn.dataset.id;
2875
- const action = btn.dataset.action;
2876
- if (id && action) adjudicateReport(id, action);
2877
- });
2878
- // Override-requests approve/deny — delegated to the table body.
2879
- $('#override-requests')?.addEventListener('click', (e) => {
2880
- const tr = e.target.closest('tr[data-request-id]');
2881
- if (!tr) return;
2882
- const id = tr.dataset.requestId;
2883
- if (e.target.classList.contains('override__approve')) decideOverride(id, 'approve');
2884
- else if (e.target.classList.contains('override__deny')) decideOverride(id, 'deny');
2885
- });
2886
- // Audit log — lazy-load on first <details> open, refresh on filter change.
2887
- $('#security-audit-details')?.addEventListener('toggle', (e) => {
2888
- if (e.target.open && !securityState.auditLoaded) {
2889
- securityState.auditLoaded = true;
2890
- loadAuditLog();
2891
- }
2892
- });
2893
- $('#f-audit-method')?.addEventListener('change', loadAuditLog);
2894
- $('#f-audit-builder')?.addEventListener('change', loadAuditLog);
2895
- $('#audit-refresh')?.addEventListener('click', loadAuditLog);
2896
-
2897
- // Skill-prefs table — change handler delegated so post-render select
2898
- // elements still fire. The empty value ("— default —") clears the override
2899
- // via the null sentinel; everything else PATCHes the named model.
2900
- const prefsTable = $('#skill-prefs-table');
2901
- if (prefsTable) {
2902
- prefsTable.addEventListener('change', (e) => {
2903
- const sel = e.target.closest('.skill-prefs__select');
2904
- if (!sel) return;
2905
- const skill = sel.dataset.skill;
2906
- if (!skill) return;
2907
- const next = sel.value === '' ? null : sel.value;
2908
- saveSkillPref(skill, next);
2909
- });
2910
- }
2911
-
2912
- // Roster — Archon-only. Filter change, threshold change, refresh button.
2913
- // Action buttons (promote / demote / release-stale) delegated to the
2914
- // table body; the table is re-rendered on every action so listeners can
2915
- // safely be one-shot at the container.
2916
- $('#f-roster-show')?.addEventListener('change', (e) => {
2917
- rosterState.show = e.target.value || 'all';
2918
- // #408: changing the filter changes the dataset, so jump back to the
2919
- // first page — otherwise a "show: onboarding (3 rows)" filter could
2920
- // leave the user stranded on page 4 of "all".
2921
- rosterState.page = 0;
2922
- renderRoster();
2923
- });
2924
- $('#f-roster-stale')?.addEventListener('change', (e) => {
2925
- const n = Number(e.target.value);
2926
- if (Number.isFinite(n) && n > 0 && n <= 720) {
2927
- rosterState.staleHours = n;
2928
- rosterState.page = 0;
2929
- loadRoster();
2930
- } else {
2931
- e.target.value = String(rosterState.staleHours);
2932
- toast('Stale threshold must be 1–720 hours.');
2933
- }
2934
- });
2935
- $('#roster-refresh')?.addEventListener('click', loadRoster);
2936
- // #408: roster pager — prev/next buttons mirroring the leaderboard
2937
- // pattern. Page index is clamped inside renderRoster, so we don't need
2938
- // to guard the bounds here.
2939
- $('#roster-prev')?.addEventListener('click', () => {
2940
- if (rosterState.page > 0) { rosterState.page--; renderRoster(); }
2941
- });
2942
- $('#roster-next')?.addEventListener('click', () => {
2943
- rosterState.page++;
2944
- renderRoster();
2945
- });
2946
- $('#roster-table')?.addEventListener('click', (e) => {
2947
- const btn = e.target.closest('button');
2948
- if (!btn) return;
2949
- const id = btn.dataset.id;
2950
- if (!id) return;
2951
- if (btn.classList.contains('roster__promote') || btn.classList.contains('roster__demote')) {
2952
- rosterChangeRank(id, btn.dataset.next, btn.dataset.current);
2953
- } else if (btn.classList.contains('roster__release-stale')) {
2954
- rosterReleaseStale(id);
2955
- }
2956
- });
2957
-
2958
2408
  // Logout link: the /api/bongos/auth/logout endpoint is POST + requires the
2959
2409
  // session cookie. Convert the anchor click into a fetch then bounce home.
2960
2410
  $('#logout-link').addEventListener('click', async (e) => {