@bongos/core 1.17.1 → 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 (233) hide show
  1. package/.bongos-core.json +521 -281
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/clients/bongos-client/README.md +1 -1
  17. package/clients/bongos-client/bongos-client.global.js +2 -0
  18. package/clients/bongos-client/index.cjs +2 -0
  19. package/clients/bongos-client/index.d.ts +4 -2
  20. package/clients/bongos-client/index.mjs +2 -0
  21. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  22. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  23. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  24. package/docs/adr/0020-security-model-public-stub.md +1 -1
  25. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  26. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  27. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  28. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  29. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  30. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  31. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  32. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  33. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  34. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  35. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  36. package/docs/adr/0057-container-cost-ledger.md +3 -3
  37. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  38. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  39. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  40. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  41. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  42. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  43. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  44. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  45. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  46. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  47. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  48. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  49. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  50. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  51. package/docs/adr/README.md +9 -6
  52. package/docs/api/openapi.json +65 -26
  53. package/docs/api-reference.md +3 -2
  54. package/docs/architecture.md +2 -2
  55. package/docs/branding-contract.md +5 -4
  56. package/docs/canonical-permissions.md +1 -1
  57. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  58. package/docs/file-map.md +18 -17
  59. package/docs/handoff-template.md +2 -2
  60. package/docs/modules-contract.md +6 -1
  61. package/docs/onboarding/diagrams/README.md +7 -7
  62. package/docs/onboarding/drachmae-and-karma.md +18 -18
  63. package/docs/onboarding/primer.md +9 -9
  64. package/docs/recipes/audit-replay.md +2 -2
  65. package/docs/recipes/builder-box-lifecycle.md +2 -2
  66. package/docs/recipes/claude-design-loop.md +1 -1
  67. package/docs/recipes/ops-gotchas.md +1 -1
  68. package/docs/recipes/private-npm-distribution.md +17 -0
  69. package/docs/recipes/staging-environment.md +1 -1
  70. package/docs/recipes/upgrading-the-core.md +55 -0
  71. package/docs/research/cursor-competitive-analysis.md +1 -1
  72. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  73. package/modules/discord/module.json +2 -1
  74. package/modules/economy/achievements.js +9 -1
  75. package/modules/economy/credits.js +10 -1
  76. package/modules/hall-ui/public/builders.js +137 -620
  77. package/modules/hall-ui/public/goal-inbox.js +232 -0
  78. package/modules/hall-ui/public/goals-page.js +4 -1
  79. package/modules/hall-ui/public/goals.html +8 -0
  80. package/modules/hall-ui/public/hall-kit.js +474 -0
  81. package/modules/hall-ui/public/hall-widgets.js +14 -5
  82. package/modules/hall-ui/public/index.html +3 -0
  83. package/modules/hall-ui/public/instances-panel.js +24 -3
  84. package/modules/hall-ui/public/new-project-wizard.js +7 -5
  85. package/modules/hall-ui/public/style.css +168 -0
  86. package/modules/hall-ui/public/work.css +194 -0
  87. package/modules/hall-ui/public/work.html +46 -374
  88. package/modules/hall-ui/public/work.js +137 -102
  89. package/modules/lifecycle/criterion-suggest.js +80 -0
  90. package/modules/lifecycle/db.js +42 -5
  91. package/modules/lifecycle/routes/goals.js +26 -0
  92. package/modules/lifecycle/routes/tasks.js +21 -0
  93. package/modules/provisioning/CLAUDE.md +15 -0
  94. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  95. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  96. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  97. package/modules/provisioning/module.json +17 -0
  98. package/modules/provisioning/onboard-plan.js +37 -0
  99. package/modules/provisioning/provisioning.js +539 -0
  100. package/modules/provisioning/rate-limit.js +35 -0
  101. package/modules/provisioning/routes/provisioning.js +607 -0
  102. package/modules/provisioning/tests/provisioning.mjs +232 -0
  103. package/modules/provisioning/ui/.gitkeep +0 -0
  104. package/modules/security/secret-scrub.js +10 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +7 -2
  107. package/scripts/discord/README.md +1 -1
  108. package/scripts/gds/claim.js +98 -48
  109. package/scripts/gds/claude-materialize.js +30 -9
  110. package/scripts/gds/cutover-codemod.js +241 -0
  111. package/scripts/gds/fitness.js +54 -0
  112. package/scripts/gds/init.js +59 -3
  113. package/scripts/gds/lint-infra-exec.js +70 -0
  114. package/scripts/gds/lint-workflow-exec.js +63 -0
  115. package/scripts/gds/mirror-lineage-check.js +14 -5
  116. package/scripts/gds/mirror-redact.js +8 -0
  117. package/scripts/gds/onboard.js +4 -0
  118. package/scripts/gds/package-core.js +18 -3
  119. package/scripts/gds/publish-manifest.js +60 -2
  120. package/scripts/gds/run-routine.js +51 -13
  121. package/scripts/gds/run-unit-tests.js +4 -0
  122. package/scripts/gds/update-channel.js +186 -0
  123. package/scripts/gds/upgrade.js +116 -9
  124. package/scripts/hall-preview/README.md +103 -0
  125. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  126. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  127. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  128. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  129. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  130. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  131. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  132. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  133. package/scripts/hall-preview/fixtures/goals.json +502 -0
  134. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  135. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  136. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  137. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  138. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  139. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  140. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  141. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  142. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  143. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  144. package/scripts/hall-preview/fixtures/instance.json +124 -0
  145. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  146. package/scripts/hall-preview/fixtures/me.json +476 -0
  147. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  148. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  149. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  150. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  151. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  152. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  153. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  154. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  155. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  156. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  157. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  158. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  159. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  160. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  161. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  162. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  163. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  164. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  165. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  166. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  167. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  168. package/scripts/hall-preview/fixtures/versions.json +89 -0
  169. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  170. package/scripts/hall-preview/server.js +178 -0
  171. package/scripts/migrate.sh +29 -4
  172. package/src/bongos/auth.js +14 -1
  173. package/src/bongos/optimizer.js +37 -2
  174. package/src/bongos/serve-internal.js +10 -1
  175. package/src/branding.js +1 -0
  176. package/src/module-api.js +12 -1
  177. package/src/module-loader/loader.js +36 -4
  178. package/src/module-loader/manifest-schema.js +16 -1
  179. package/tests/branding.mjs +11 -0
  180. package/tests/claim_error_surface.mjs +107 -0
  181. package/tests/claude_materialize.mjs +20 -0
  182. package/tests/consumer_layout_boot.mjs +302 -0
  183. package/tests/criterion_suggest.mjs +106 -0
  184. package/tests/cutover_codemod.mjs +184 -0
  185. package/tests/device_flow_start.mjs +25 -2
  186. package/tests/hook_core_resolve.mjs +125 -0
  187. package/tests/infra_exec_paths.mjs +104 -0
  188. package/tests/init.mjs +47 -0
  189. package/tests/migrate_root_resolution.mjs +97 -0
  190. package/tests/mirror_lineage_proof.mjs +14 -0
  191. package/tests/module_loader.mjs +42 -0
  192. package/tests/module_manifest.mjs +19 -0
  193. package/tests/onboard.mjs +13 -0
  194. package/tests/package_core.mjs +8 -0
  195. package/tests/provisioning_github_app_routes.mjs +15 -0
  196. package/tests/publish_manifest.mjs +52 -1
  197. package/tests/run_routine.mjs +26 -1
  198. package/tests/run_routine_two_root.mjs +77 -0
  199. package/tests/scrubber_corpus.mjs +17 -0
  200. package/tests/update_channel.mjs +186 -0
  201. package/tests/upgrade.mjs +131 -0
  202. package/tests/workflow_exec_paths.mjs +98 -0
  203. package/docs/design/container-exterior-tiles.md +0 -49
  204. package/docs/design/container-exterior.md +0 -81
  205. package/docs/design/container-interior.md +0 -77
  206. package/docs/design/world-map-scroll.md +0 -41
  207. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  208. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  209. package/docs/recipes/cloudbongos-standup.md +0 -111
  210. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  211. package/docs/recipes/parallel-load-test.md +0 -32
  212. package/docs/recipes/pixel-art-pipeline.md +0 -411
  213. package/docs/recipes/staging-data.md +0 -50
  214. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  215. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  216. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  217. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  218. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  219. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  220. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  221. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  222. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  223. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  224. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  225. package/scripts/gds/art-stage.js +0 -152
  226. package/server.js +0 -212
  227. package/tests/art_key.mjs +0 -121
  228. package/tests/day_cycle.mjs +0 -197
  229. package/tests/snapshot_picker.mjs +0 -233
  230. package/tests/sprite_facing.mjs +0 -35
  231. package/tests/terrain_picker.mjs +0 -298
  232. package/tests/turn_in_place.mjs +0 -280
  233. package/tests/walkability.mjs +0 -188
@@ -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);
@@ -1782,6 +1850,42 @@
1782
1850
  }).join('');
1783
1851
  }
1784
1852
 
1853
+ // ---- idea-inbox triage cue (task 1471) -----------------------------------
1854
+
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.
1861
+ function renderTriageInbox(data) {
1862
+ const box = document.getElementById('attention-ideas');
1863
+ if (!box) return;
1864
+ const open = (data && Array.isArray(data.ideas) ? data.ideas : [])
1865
+ .filter((i) => i && i.status === 'open');
1866
+ const openCount = data && data.open_count != null ? data.open_count : open.length;
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}`;
1885
+ }
1886
+ window.OTBAttention?.refresh();
1887
+ }
1888
+
1785
1889
  // ---- leaderboard ---------------------------------------------------------
1786
1890
 
1787
1891
  // Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
@@ -1816,7 +1920,7 @@
1816
1920
  page: 0,
1817
1921
  pageSize: 25, // task 1750: top-100 Roll — 25/page keeps it to a few pages
1818
1922
 
1819
- sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma'
1923
+ sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
1820
1924
  sortDir: 'desc', // 'desc' | 'asc'
1821
1925
  };
1822
1926
 
@@ -1852,6 +1956,23 @@
1852
1956
  })
1853
1957
  .map(({ row }) => row);
1854
1958
  }
1959
+ if (lbState.sortBy === 'tokens') {
1960
+ // task 1473: sort by total tokens used. tokens_used arrives as a string
1961
+ // (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
1962
+ // matching the karma branch.
1963
+ return indexed
1964
+ .sort((a, b) => {
1965
+ const at = Number(a.row.tokens_used) || 0;
1966
+ const bt = Number(b.row.tokens_used) || 0;
1967
+ const diff = (at - bt) * dir;
1968
+ if (diff !== 0) return diff;
1969
+ const ac = Number(a.row.total_credits) || 0;
1970
+ const bc = Number(b.row.total_credits) || 0;
1971
+ if (ac !== bc) return bc - ac;
1972
+ return a.idx - b.idx;
1973
+ })
1974
+ .map(({ row }) => row);
1975
+ }
1855
1976
  if (lbState.sortBy === 'drachmae') {
1856
1977
  // Server already DESCs by drachmae, so the default mode is a no-op
1857
1978
  // copy. The ASC branch flips it.
@@ -1869,13 +1990,14 @@
1869
1990
  }
1870
1991
 
1871
1992
  // Map a sortable column id → the lbState.sortBy key. Adding a new sortable
1872
- // column means adding an entry here + a header in index.html with id
1873
- // `lb-sort-<key>`. The handler walks aria-sort across all three headers so
1993
+ // column means adding an entry here + a header in hall-widgets.js with id
1994
+ // `lb-sort-<key>`. The handler walks aria-sort across all sortable headers so
1874
1995
  // only one column shows its arrow at a time.
1875
1996
  const LB_SORT_HEADERS = {
1876
1997
  rank: 'lb-sort-rank',
1877
1998
  drachmae: 'lb-sort-drachmae',
1878
1999
  karma: 'lb-sort-karma',
2000
+ tokens: 'lb-sort-tokens',
1879
2001
  };
1880
2002
 
1881
2003
  // #757: this builder's place on the Roll, ranked by drachmae (total_credits)
@@ -1902,7 +2024,7 @@
1902
2024
  const tbody = $('#leaderboard tbody');
1903
2025
 
1904
2026
  if (!slice.length) {
1905
- tbody.innerHTML = emptyRow(5, 'No more builders to show.');
2027
+ tbody.innerHTML = emptyRow(6, 'No more builders to show.');
1906
2028
  } else {
1907
2029
  tbody.innerHTML = slice.map((b, i) => {
1908
2030
  const place = start + i + 1;
@@ -1922,6 +2044,7 @@
1922
2044
  <td class="ledger__rank">${rank}</td>
1923
2045
  <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
1924
2046
  <td class="ledger__drachmae">${fmtNum(b.karma)}</td>
2047
+ <td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
1925
2048
  </tr>
1926
2049
  `;
1927
2050
  }).join('');
@@ -1968,525 +2091,6 @@
1968
2091
  renderLeaderboard();
1969
2092
  }
1970
2093
 
1971
- // ---- Archon security watch (V3.R66 / #285) -------------------------------
1972
- //
1973
- // Hidden by default; revealed only when /me returns rank='archon'. The
1974
- // table renders open vulnerability reports + adjudication actions
1975
- // (confirm / dispute / fix / link-fix). The audit log is collapsed
1976
- // behind a <details> and loads on first open.
1977
-
1978
- const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
1979
-
1980
- let securityState = {
1981
- statusFilter: '',
1982
- auditLoaded: false,
1983
- };
1984
-
1985
- function severityBadge(sev) {
1986
- const cls = `sev-${sev}`;
1987
- return `<span class="sev-badge ${cls}">${escapeHtml(sev || '?')}</span>`;
1988
- }
1989
-
1990
- function renderSecurityReports(payload) {
1991
- const tbody = document.querySelector('#security-reports tbody');
1992
- if (!tbody) return;
1993
- const reports = Array.isArray(payload?.reports) ? payload.reports : [];
1994
- const bounty = payload?.bounty_table || {};
1995
- if (reports.length === 0) {
1996
- tbody.innerHTML = emptyRow(7, 'No reports match this filter.');
1997
- return;
1998
- }
1999
- // Sort: open first (by severity desc), then everything else newest first.
2000
- const sorted = reports.slice().sort((a, b) => {
2001
- const aOpen = a.status === 'open' || a.status === 'repro_attempted' ? 1 : 0;
2002
- const bOpen = b.status === 'open' || b.status === 'repro_attempted' ? 1 : 0;
2003
- if (aOpen !== bOpen) return bOpen - aOpen;
2004
- const aS = SEVERITY_RANK[a.severity] || 0;
2005
- const bS = SEVERITY_RANK[b.severity] || 0;
2006
- if (aS !== bS) return bS - aS;
2007
- return new Date(b.reported_at) - new Date(a.reported_at);
2008
- });
2009
- tbody.innerHTML = sorted.map((r) => {
2010
- const reporter = r.reporter_builder_id ? `#${escapeHtml(String(r.reporter_builder_id))}` : '—';
2011
- const drachmae = r.drachmae_awarded != null ? fmtNum(r.drachmae_awarded) : (bounty[r.severity] ? `(${fmtNum(bounty[r.severity])} if confirmed)` : '—');
2012
- const actions = renderSecurityActions(r);
2013
- return `
2014
- <tr data-report-id="${r.id}">
2015
- <td class="ledger__num">${escapeHtml(String(r.id))}</td>
2016
- <td>${severityBadge(r.severity)}</td>
2017
- <td>${escapeHtml(r.title || '')}<div style="font-size:0.78rem;opacity:0.7;">${escapeHtml(r.target || '')}</div></td>
2018
- <td>${escapeHtml(r.status || '')}</td>
2019
- <td>${reporter}</td>
2020
- <td class="ledger__drachmae">${drachmae}</td>
2021
- <td>${actions}</td>
2022
- </tr>
2023
- `;
2024
- }).join('');
2025
- }
2026
-
2027
- function renderSecurityActions(report) {
2028
- const id = report.id;
2029
- const isTerminal = report.status === 'fixed' || report.status === 'wontfix';
2030
- const buttons = [];
2031
- if (!isTerminal && report.status !== 'confirmed') {
2032
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="confirm" data-id="${id}">Confirm</button>`);
2033
- }
2034
- if (!isTerminal && report.status !== 'disputed') {
2035
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="dispute" data-id="${id}">Dispute</button>`);
2036
- }
2037
- if (report.status !== 'fixed') {
2038
- buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="fix" data-id="${id}">Mark fixed</button>`);
2039
- }
2040
- 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>`);
2041
- return buttons.join(' ');
2042
- }
2043
-
2044
- async function loadSecurityReports() {
2045
- const status = securityState.statusFilter;
2046
- const url = status ? `${API}/security/reports?status=${encodeURIComponent(status)}` : `${API}/security/reports`;
2047
- try {
2048
- const payload = await getJSON(url);
2049
- renderSecurityReports(payload);
2050
- } catch (err) {
2051
- if (err && err.message === 'redirecting to auth') return;
2052
- const tbody = document.querySelector('#security-reports tbody');
2053
- if (tbody) tbody.innerHTML = emptyRow(7, 'Could not load reports.');
2054
- console.error('[builders] load security reports failed', err);
2055
- }
2056
- }
2057
-
2058
- async function adjudicateReport(id, action) {
2059
- const status = $('#security-status');
2060
- if (status) { status.hidden = false; status.textContent = 'Working…'; }
2061
- try {
2062
- let body = {};
2063
- let path = `${API}/security/reports/${encodeURIComponent(id)}/${action}`;
2064
- if (action === 'link-fix') {
2065
- const taskIdRaw = window.prompt(`Task ID to link as the fix for report #${id}?`, '');
2066
- if (taskIdRaw == null) {
2067
- if (status) status.textContent = 'Cancelled.';
2068
- return;
2069
- }
2070
- const taskId = Number(taskIdRaw);
2071
- if (!Number.isInteger(taskId) || taskId <= 0) {
2072
- if (status) status.textContent = 'Bad task id — must be a positive integer.';
2073
- return;
2074
- }
2075
- body = { task_id: taskId };
2076
- }
2077
- const resp = await api.request('POST', path, { body });
2078
- if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); throw new Error('redirecting to auth'); }
2079
- const data = resp.data || {};
2080
- if (!resp.ok) {
2081
- if (status) status.textContent = `Failed: ${data.error || resp.status}`;
2082
- return;
2083
- }
2084
- if (status) status.textContent = `Report #${id} → ${data.report?.status || action}`;
2085
- await loadSecurityReports();
2086
- } catch (err) {
2087
- if (err && err.message === 'redirecting to auth') return;
2088
- console.error('[builders] adjudication failed', err);
2089
- if (status) status.textContent = 'Adjudication failed. Try again.';
2090
- }
2091
- }
2092
-
2093
- async function loadAuditLog() {
2094
- const tbody = document.querySelector('#security-audit-log tbody');
2095
- if (!tbody) return;
2096
- tbody.innerHTML = emptyRow(5, 'Loading…');
2097
- const method = $('#f-audit-method')?.value || '';
2098
- const builderId = $('#f-audit-builder')?.value || '';
2099
- const params = new URLSearchParams();
2100
- params.set('limit', '50');
2101
- if (method) params.set('method', method);
2102
- if (builderId) params.set('builder_id', builderId);
2103
- try {
2104
- const payload = await getJSON(`${API}/audit-log?${params}`);
2105
- const rows = Array.isArray(payload?.rows) ? payload.rows : [];
2106
- if (rows.length === 0) {
2107
- tbody.innerHTML = emptyRow(5, 'No matching audit entries.');
2108
- return;
2109
- }
2110
- tbody.innerHTML = rows.map((r) => `
2111
- <tr>
2112
- <td>${escapeHtml(fmtDate(r.recorded_at))}</td>
2113
- <td>#${escapeHtml(String(r.builder_id))}</td>
2114
- <td>${escapeHtml(r.method)}</td>
2115
- <td><code>${escapeHtml(r.route)}</code></td>
2116
- <td>${escapeHtml(String(r.response_status))}</td>
2117
- </tr>
2118
- `).join('');
2119
- } catch (err) {
2120
- if (err && err.message === 'redirecting to auth') return;
2121
- console.error('[builders] audit-log load failed', err);
2122
- tbody.innerHTML = emptyRow(5, 'Could not load audit log.');
2123
- }
2124
- }
2125
-
2126
- // task 1723: unhideArchonView is gone — the Archon nav group (Watch, Harbor,
2127
- // Gate, Sessions) is the shell's, revealed by renderNav's applyAccess call
2128
- // once /me confirms rank === 'archon' (shell.js gates Harbor on the dev-box
2129
- // module too). The data still loads only on the Archon pages, each behind its
2130
- // own archon-gated endpoint (#726).
2131
-
2132
- // ---- Archon roster + management (V3.R14 / #238) --------------------------
2133
- //
2134
- // Archon-only roster of every builder, with promote / demote /
2135
- // release-stale-claim controls. Hidden via #roster-scroll[hidden] for
2136
- // non-Archons; unhideArchonView flips it on.
2137
- //
2138
- // Rank ladder is xenos < thetes < metic < archon (RANK_ORDER above). Promote
2139
- // walks the ladder up ONE rung; demote walks it down one. The server enforces
2140
- // the one-rung-promotion rule (#707), so the button must offer the true next
2141
- // rung per row — a xenos promotes to thetes (not metic), a thetes to metic.
2142
-
2143
- const ROSTER_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
2144
- // #408: paginate the Archon roster — it can grow past a screenful as the
2145
- // community scales (200+ rows seen during stress test). 20 rows/page
2146
- // matches the cardinality of "one comfortable scroll" inside the section
2147
- // without forcing the user to scan further than they can hold in working
2148
- // memory. Filter dropdowns still scope the dataset *before* pagination,
2149
- // so a Show:onboarding filter still narrows to onboarding builders.
2150
- const ROSTER_PAGE_SIZE = 20;
2151
-
2152
- let rosterState = {
2153
- rows: [],
2154
- show: 'all',
2155
- staleHours: 24,
2156
- selfId: null,
2157
- loading: false,
2158
- page: 0,
2159
- };
2160
-
2161
- function nextRank(rank, direction /* 'up' | 'down' */) {
2162
- const idx = ROSTER_LADDER.indexOf((rank || '').toLowerCase());
2163
- if (idx < 0) return null;
2164
- if (direction === 'up' && idx < ROSTER_LADDER.length - 1) return ROSTER_LADDER[idx + 1];
2165
- if (direction === 'down' && idx > 0) return ROSTER_LADDER[idx - 1];
2166
- return null;
2167
- }
2168
-
2169
- function fmtTtfsMinutes(min) {
2170
- if (min === null || min === undefined) return '—';
2171
- const n = Number(min);
2172
- if (!Number.isFinite(n) || n < 0) return '—';
2173
- if (n < 60) return `${n}m`;
2174
- const hours = n / 60;
2175
- if (hours < 24) return `${hours.toFixed(1)}h`;
2176
- const days = hours / 24;
2177
- if (days < 30) return `${days.toFixed(1)}d`;
2178
- const months = days / 30;
2179
- return `${months.toFixed(1)}mo`;
2180
- }
2181
-
2182
- function rosterFilterRows(rows) {
2183
- switch (rosterState.show) {
2184
- case 'onboarding':
2185
- return rows.filter((r) => !r.first_ship_at && r.status !== 'inactive');
2186
- case 'active':
2187
- return rows.filter((r) => Number(r.active_claim_count) > 0);
2188
- case 'stale':
2189
- return rows.filter((r) => r.stale_claim);
2190
- case 'inactive':
2191
- return rows.filter((r) => r.status === 'inactive');
2192
- case 'all':
2193
- default:
2194
- return rows;
2195
- }
2196
- }
2197
-
2198
- function renderRoster() {
2199
- const tbody = document.querySelector('#roster-table tbody');
2200
- const countEl = $('#roster-count');
2201
- const pagerEl = $('#roster-pager');
2202
- const rangeEl = $('#roster-range');
2203
- const prevBtn = $('#roster-prev');
2204
- const nextBtn = $('#roster-next');
2205
- if (!tbody) return;
2206
- const rows = rosterFilterRows(rosterState.rows);
2207
- if (countEl) {
2208
- countEl.textContent = `${rows.length} / ${rosterState.rows.length}`;
2209
- }
2210
- if (rows.length === 0) {
2211
- tbody.innerHTML = emptyRow(9, 'No builders match this filter.');
2212
- if (pagerEl) pagerEl.hidden = true;
2213
- return;
2214
- }
2215
- // #408: client-side pagination. renderPager clamps the page index in case a
2216
- // filter/refresh shrank the dataset under our feet (e.g. on page 4, then
2217
- // switch to a filter with only 30 matching rows → snap back to the last
2218
- // legal page) and updates the range + prev/next controls.
2219
- const { start } = renderPager(rosterState, rows.length, ROSTER_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
2220
- const slice = rows.slice(start, start + ROSTER_PAGE_SIZE);
2221
-
2222
- tbody.innerHTML = slice.map((b) => {
2223
- const isSelf = String(b.id) === String(rosterState.selfId);
2224
- const isInactive = b.status === 'inactive';
2225
- const upRank = nextRank(b.rank, 'up');
2226
- const downRank = nextRank(b.rank, 'down');
2227
- const claimCount = Number(b.active_claim_count) || 0;
2228
- const stale = !!b.stale_claim;
2229
- const claimsCell = claimCount === 0
2230
- ? '<span style="opacity:0.6;">none</span>'
2231
- : `${claimCount}${stale ? ' <span class="roster__stale" title="At least one claim is stale (no pulse beyond threshold)">⚠ stale</span>' : ''}`;
2232
- const actions = [];
2233
- if (isSelf) {
2234
- actions.push('<span style="opacity:0.55;font-size:0.8rem;">— self —</span>');
2235
- } else if (isInactive) {
2236
- actions.push('<span style="opacity:0.55;font-size:0.8rem;">inactive</span>');
2237
- } else {
2238
- if (upRank) {
2239
- 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>`);
2240
- }
2241
- if (downRank) {
2242
- 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>`);
2243
- }
2244
- if (stale) {
2245
- actions.push(`<button type="button" class="btn-ghost roster__release-stale" data-id="${escapeHtml(String(b.id))}">Release stale claim</button>`);
2246
- }
2247
- }
2248
- const inactiveTag = isInactive ? ' <span class="roster__inactive">inactive</span>' : '';
2249
- const selfTag = isSelf ? ' <span class="roster__self">you</span>' : '';
2250
- const handle = b.display_name || b.github_login || `builder #${b.id}`;
2251
- return `
2252
- <tr data-builder-id="${escapeHtml(String(b.id))}">
2253
- <td>${escapeHtml(handle)}${selfTag}${inactiveTag}<div style="font-size:0.75rem;opacity:0.65;">#${escapeHtml(String(b.id))} · @${escapeHtml(b.github_login || '')}</div></td>
2254
- <td>${renderRankBadge(b.rank)}</td>
2255
- <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
2256
- <td>${fmtNum(b.karma)}</td>
2257
- <td class="ledger__date">${escapeHtml(fmtDate(b.onboarded_at))}</td>
2258
- <td class="ledger__date">${b.first_ship_at ? escapeHtml(fmtDate(b.first_ship_at)) : '<span style="opacity:0.6;">— not yet —</span>'}</td>
2259
- <td>${fmtTtfsMinutes(b.time_to_first_ship_minutes)}</td>
2260
- <td>${claimsCell}</td>
2261
- <td>${actions.join(' ')}</td>
2262
- </tr>
2263
- `;
2264
- }).join('');
2265
- }
2266
-
2267
- async function loadRoster() {
2268
- const status = $('#roster-status');
2269
- if (rosterState.loading) return;
2270
- rosterState.loading = true;
2271
- if (status) { status.hidden = false; status.textContent = 'Loading roster…'; }
2272
- try {
2273
- const payload = await getJSON(`${API}/builders/roster?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2274
- rosterState.rows = Array.isArray(payload?.builders) ? payload.builders : [];
2275
- renderRoster();
2276
- if (status) status.hidden = true;
2277
- } catch (err) {
2278
- if (err && err.message === 'redirecting to auth') return;
2279
- console.error('[builders] load roster failed', err);
2280
- const tbody = document.querySelector('#roster-table tbody');
2281
- if (tbody) tbody.innerHTML = emptyRow(9, 'Could not load the roster.');
2282
- if (status) { status.hidden = false; status.textContent = `Could not load roster: ${err.message}`; }
2283
- } finally {
2284
- rosterState.loading = false;
2285
- }
2286
- }
2287
-
2288
- async function rosterChangeRank(builderId, nextRankName, currentRankName) {
2289
- const status = $('#roster-status');
2290
- const ok = window.confirm(
2291
- `Change builder #${builderId} from ${currentRankName || '?'} to ${nextRankName}?`
2292
- );
2293
- if (!ok) return;
2294
- if (status) { status.hidden = false; status.textContent = `Setting rank to ${nextRankName}…`; }
2295
- try {
2296
- const resp = await patchJSON(`${API}/builders/${encodeURIComponent(builderId)}/rank`, { rank: nextRankName });
2297
- const newRank = resp?.builder?.rank || nextRankName;
2298
- if (status) status.textContent = `Builder #${builderId} → ${newRank}.`;
2299
- toast(`Builder #${builderId} is now ${newRank}.`);
2300
- await loadRoster();
2301
- } catch (err) {
2302
- if (err && err.message === 'redirecting to auth') return;
2303
- console.error('[builders] rank change failed', err);
2304
- if (status) { status.hidden = false; status.textContent = `Rank change failed: ${err.message}`; }
2305
- toast('Rank change failed.');
2306
- }
2307
- }
2308
-
2309
- async function rosterReleaseStale(builderId) {
2310
- const status = $('#roster-status');
2311
- let claims;
2312
- if (status) { status.hidden = false; status.textContent = 'Looking up stale claims…'; }
2313
- try {
2314
- const payload = await getJSON(`${API}/builders/${encodeURIComponent(builderId)}/active-claims?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2315
- claims = Array.isArray(payload?.claims) ? payload.claims : [];
2316
- } catch (err) {
2317
- if (err && err.message === 'redirecting to auth') return;
2318
- if (status) status.textContent = `Could not fetch claims: ${err.message}`;
2319
- return;
2320
- }
2321
- const stale = claims.filter((c) => c.stale_claim);
2322
- if (stale.length === 0) {
2323
- if (status) status.textContent = 'No stale claims left to release for that builder.';
2324
- // The roster row was probably stale-from-cache — refresh.
2325
- await loadRoster();
2326
- return;
2327
- }
2328
- // Pick the oldest stale claim (the queue order matches claimed_at ASC
2329
- // from the server). For multiple stale claims, surface the count so
2330
- // the Archon knows another action may be needed.
2331
- const target = stale[0];
2332
- const reason = window.prompt(
2333
- `Release stale claim #${target.claim_id} (task #${target.task_id} "${target.task_title || ''}") for builder #${builderId}?\n\nReason (required):`,
2334
- 'pulse heartbeat went silent; freeing the task'
2335
- );
2336
- if (reason == null) {
2337
- if (status) status.textContent = 'Cancelled.';
2338
- return;
2339
- }
2340
- const trimmed = String(reason).trim();
2341
- if (!trimmed) {
2342
- if (status) status.textContent = 'A reason is required for an on-behalf release.';
2343
- return;
2344
- }
2345
- if (status) status.textContent = `Releasing claim #${target.claim_id}…`;
2346
- try {
2347
- const resp = await api.request('POST', `${API}/claims/${encodeURIComponent(target.claim_id)}/release-on-behalf`, { body: { reason: trimmed } });
2348
- if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2349
- const data = resp.data || {};
2350
- if (!resp.ok) {
2351
- if (status) status.textContent = `Release failed: ${data.error || resp.status}`;
2352
- toast('Release failed.');
2353
- return;
2354
- }
2355
- const remaining = stale.length - 1;
2356
- 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.` : '');
2357
- toast('Claim released.');
2358
- await loadRoster();
2359
- } catch (err) {
2360
- if (status) status.textContent = `Release failed: ${err.message}`;
2361
- toast('Release failed.');
2362
- }
2363
- }
2364
-
2365
- // ---- override requests (V3.R74 / #293) -----------------------------------
2366
- //
2367
- // Archon-only. Lists open requests where a builder asked the Archon to
2368
- // override a failed grader. Approve flips task to confirmed + pays the
2369
- // requester; deny only stamps the decision row.
2370
-
2371
- async function loadOverrideRequests() {
2372
- const tbody = document.querySelector('#override-requests tbody');
2373
- if (!tbody) return;
2374
- try {
2375
- const data = await getJSON(`${API}/override-requests?status=open`);
2376
- const rows = Array.isArray(data?.requests) ? data.requests : [];
2377
- if (rows.length === 0) {
2378
- tbody.innerHTML = emptyRow(6, 'No open requests.');
2379
- return;
2380
- }
2381
- tbody.innerHTML = rows.map((r) => `
2382
- <tr data-request-id="${escapeHtml(String(r.id))}">
2383
- <td>${escapeHtml(String(r.id))}</td>
2384
- <td>#${escapeHtml(String(r.task_id))} — ${escapeHtml(r.task_title || '')}</td>
2385
- <td>@${escapeHtml(r.requester_login || '')}</td>
2386
- <td class="override__rationale">${escapeHtml(r.rationale || '')}</td>
2387
- <td>${escapeHtml(String(r.credits_reward ?? '—'))}</td>
2388
- <td>
2389
- <button class="btn-ghost override__approve" type="button">Approve</button>
2390
- <button class="btn-ghost override__deny" type="button">Deny</button>
2391
- </td>
2392
- </tr>
2393
- `).join('');
2394
- } catch (err) {
2395
- if (err && err.message === 'redirecting to auth') return;
2396
- console.error('[builders] override-requests failed', err);
2397
- tbody.innerHTML = emptyRow(6, 'Could not load override requests.');
2398
- }
2399
- }
2400
-
2401
- async function decideOverride(requestId, decision) {
2402
- const status = $('#override-status');
2403
- const verb = decision === 'approve' ? 'Approve' : 'Deny';
2404
- const note = window.prompt(`${verb} request #${requestId} — optional note (sent to audit log):`, '');
2405
- if (note === null) return; // user cancelled
2406
- try {
2407
- const res = await api.request('POST', `${API}/override-requests/${encodeURIComponent(requestId)}/decide`, { body: { decision, note: note || undefined } });
2408
- if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2409
- const body = res.data || {};
2410
- if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
2411
- if (status) {
2412
- status.hidden = false;
2413
- status.textContent = decision === 'approve'
2414
- ? `Approved — task flipped to confirmed, ${curLabel()} paid: ${body.confirmResult?.creditsAwarded ?? 0}.`
2415
- : `Denied — the task stays at completed.`;
2416
- }
2417
- await loadOverrideRequests();
2418
- } catch (err) {
2419
- console.error('[builders] decide override failed', err);
2420
- if (status) { status.hidden = false; status.textContent = `Could not decide: ${err.message}`; }
2421
- }
2422
- }
2423
-
2424
- // ---- skill model preferences (V3.R85 / #304) -----------------------------
2425
- //
2426
- // The matrix lets each builder override the system default model for each
2427
- // slash-command skill. System defaults come from the audit in
2428
- // src/bongos/skill-prefs.js (mechanism vs judgment classification) and are
2429
- // echoed back in the GET response so the table can label them.
2430
-
2431
- const MODEL_LABELS = {
2432
- opus: 'Opus',
2433
- sonnet: 'Sonnet',
2434
- haiku: 'Haiku',
2435
- script: 'no LLM',
2436
- };
2437
-
2438
- function modelLabel(m) {
2439
- return MODEL_LABELS[m] || m || '—';
2440
- }
2441
-
2442
- function renderSkillPrefs(data) {
2443
- const tbody = $('#skill-prefs-table tbody');
2444
- if (!tbody) return;
2445
- const rows = Array.isArray(data?.effective) ? data.effective : [];
2446
- const allowed = Array.isArray(data?.allowed_models) ? data.allowed_models : ['opus', 'sonnet', 'haiku', 'script'];
2447
- if (rows.length === 0) {
2448
- tbody.innerHTML = emptyRow(3, 'No skills registered.');
2449
- return;
2450
- }
2451
- tbody.innerHTML = rows.map((row) => {
2452
- const opts = ['<option value="">— default —</option>']
2453
- .concat(allowed.map((m) => {
2454
- const sel = row.override_model === m ? ' selected' : '';
2455
- return `<option value="${m}"${sel}>${modelLabel(m)}</option>`;
2456
- }))
2457
- .join('');
2458
- const defaultCell = `<span class="skill-prefs__default" title="System default for this skill">${modelLabel(row.default_model)}</span>`;
2459
- return `
2460
- <tr data-skill="${escapeHtml(row.skill)}">
2461
- <td><code>${escapeHtml(row.skill)}</code></td>
2462
- <td>${defaultCell}</td>
2463
- <td>
2464
- <select class="skill-prefs__select" data-skill="${escapeHtml(row.skill)}" aria-label="Model for ${escapeHtml(row.skill)}">
2465
- ${opts}
2466
- </select>
2467
- </td>
2468
- </tr>
2469
- `;
2470
- }).join('');
2471
- }
2472
-
2473
- async function saveSkillPref(skill, modelOrNull) {
2474
- const status = $('#skill-prefs-status');
2475
- if (status) { status.hidden = false; status.textContent = 'Saving…'; }
2476
- try {
2477
- const body = { [skill]: modelOrNull };
2478
- const resp = await patchJSON(`${API}/me/skill-prefs`, body);
2479
- renderSkillPrefs(resp);
2480
- if (status) status.textContent = modelOrNull
2481
- ? `Set ${skill} → ${modelLabel(modelOrNull)}.`
2482
- : `Cleared ${skill} override (falls back to default).`;
2483
- } catch (err) {
2484
- if (err && err.message === 'redirecting to auth') return;
2485
- console.error('[builders] save skill pref failed', err);
2486
- if (status) status.textContent = 'Could not save. Try again.';
2487
- }
2488
- }
2489
-
2490
2094
  // ---- onboarding live-tick poll (task 1444) -------------------------------
2491
2095
  //
2492
2096
  // The onboarding checklist is rendered once from the page-load /me snapshot.
@@ -2621,8 +2225,6 @@
2621
2225
  // /me because it spans all builders' work), loaded fire-and-forget so a
2622
2226
  // slow/failed feed can't hold up the rest of the hall.
2623
2227
  loadAcclaimFeed();
2624
- // Capture self id for the roster's "cannot change your own rank" guard.
2625
- rosterState.selfId = me.builder?.id ?? null;
2626
2228
 
2627
2229
  // #408: build the sticky nav now that every section has settled its
2628
2230
  // hidden state (welcome → karma → onboarding → security/roster).
@@ -2633,6 +2235,15 @@
2633
2235
  applyNewcomerGate();
2634
2236
  renderNav(me.builder, me.onboarding || null);
2635
2237
 
2238
+ // task 1471: idea-inbox triage cue — Metic+ only (triage is a Metic+ action).
2239
+ // Fetched fire-and-forget so a slow/large inbox can't hold up the hall; the
2240
+ // widget is hiddenByDefault, so a non-triager never loads or sees idea data.
2241
+ if (['metic', 'archon'].includes(normalizeRank(me.builder?.rank))) {
2242
+ getJSON(`${API}/inbox`)
2243
+ .then((data) => renderTriageInbox(data))
2244
+ .catch(() => { /* nice-to-have; never break the hall */ });
2245
+ }
2246
+
2636
2247
  // Then the rest, in parallel. Any individual section failure shouldn't take
2637
2248
  // down the page — log and show an inline placeholder.
2638
2249
  // #726: the per-skill model picker (Voices) moved to /builders/settings,
@@ -2794,100 +2405,6 @@
2794
2405
  });
2795
2406
  }
2796
2407
 
2797
- // Security watch (Archon-only) — status filter + delegated action clicks.
2798
- // The section is hidden for non-Archons via unhideArchonView, so these
2799
- // listeners are safe to attach unconditionally (they just won't fire).
2800
- $('#f-security-status')?.addEventListener('change', (e) => {
2801
- securityState.statusFilter = e.target.value || '';
2802
- loadSecurityReports();
2803
- });
2804
- $('#security-reports')?.addEventListener('click', (e) => {
2805
- const btn = e.target.closest('.sec-act');
2806
- if (!btn) return;
2807
- const id = btn.dataset.id;
2808
- const action = btn.dataset.action;
2809
- if (id && action) adjudicateReport(id, action);
2810
- });
2811
- // Override-requests approve/deny — delegated to the table body.
2812
- $('#override-requests')?.addEventListener('click', (e) => {
2813
- const tr = e.target.closest('tr[data-request-id]');
2814
- if (!tr) return;
2815
- const id = tr.dataset.requestId;
2816
- if (e.target.classList.contains('override__approve')) decideOverride(id, 'approve');
2817
- else if (e.target.classList.contains('override__deny')) decideOverride(id, 'deny');
2818
- });
2819
- // Audit log — lazy-load on first <details> open, refresh on filter change.
2820
- $('#security-audit-details')?.addEventListener('toggle', (e) => {
2821
- if (e.target.open && !securityState.auditLoaded) {
2822
- securityState.auditLoaded = true;
2823
- loadAuditLog();
2824
- }
2825
- });
2826
- $('#f-audit-method')?.addEventListener('change', loadAuditLog);
2827
- $('#f-audit-builder')?.addEventListener('change', loadAuditLog);
2828
- $('#audit-refresh')?.addEventListener('click', loadAuditLog);
2829
-
2830
- // Skill-prefs table — change handler delegated so post-render select
2831
- // elements still fire. The empty value ("— default —") clears the override
2832
- // via the null sentinel; everything else PATCHes the named model.
2833
- const prefsTable = $('#skill-prefs-table');
2834
- if (prefsTable) {
2835
- prefsTable.addEventListener('change', (e) => {
2836
- const sel = e.target.closest('.skill-prefs__select');
2837
- if (!sel) return;
2838
- const skill = sel.dataset.skill;
2839
- if (!skill) return;
2840
- const next = sel.value === '' ? null : sel.value;
2841
- saveSkillPref(skill, next);
2842
- });
2843
- }
2844
-
2845
- // Roster — Archon-only. Filter change, threshold change, refresh button.
2846
- // Action buttons (promote / demote / release-stale) delegated to the
2847
- // table body; the table is re-rendered on every action so listeners can
2848
- // safely be one-shot at the container.
2849
- $('#f-roster-show')?.addEventListener('change', (e) => {
2850
- rosterState.show = e.target.value || 'all';
2851
- // #408: changing the filter changes the dataset, so jump back to the
2852
- // first page — otherwise a "show: onboarding (3 rows)" filter could
2853
- // leave the user stranded on page 4 of "all".
2854
- rosterState.page = 0;
2855
- renderRoster();
2856
- });
2857
- $('#f-roster-stale')?.addEventListener('change', (e) => {
2858
- const n = Number(e.target.value);
2859
- if (Number.isFinite(n) && n > 0 && n <= 720) {
2860
- rosterState.staleHours = n;
2861
- rosterState.page = 0;
2862
- loadRoster();
2863
- } else {
2864
- e.target.value = String(rosterState.staleHours);
2865
- toast('Stale threshold must be 1–720 hours.');
2866
- }
2867
- });
2868
- $('#roster-refresh')?.addEventListener('click', loadRoster);
2869
- // #408: roster pager — prev/next buttons mirroring the leaderboard
2870
- // pattern. Page index is clamped inside renderRoster, so we don't need
2871
- // to guard the bounds here.
2872
- $('#roster-prev')?.addEventListener('click', () => {
2873
- if (rosterState.page > 0) { rosterState.page--; renderRoster(); }
2874
- });
2875
- $('#roster-next')?.addEventListener('click', () => {
2876
- rosterState.page++;
2877
- renderRoster();
2878
- });
2879
- $('#roster-table')?.addEventListener('click', (e) => {
2880
- const btn = e.target.closest('button');
2881
- if (!btn) return;
2882
- const id = btn.dataset.id;
2883
- if (!id) return;
2884
- if (btn.classList.contains('roster__promote') || btn.classList.contains('roster__demote')) {
2885
- rosterChangeRank(id, btn.dataset.next, btn.dataset.current);
2886
- } else if (btn.classList.contains('roster__release-stale')) {
2887
- rosterReleaseStale(id);
2888
- }
2889
- });
2890
-
2891
2408
  // Logout link: the /api/bongos/auth/logout endpoint is POST + requires the
2892
2409
  // session cookie. Convert the anchor click into a fetch then bounce home.
2893
2410
  $('#logout-link').addEventListener('click', async (e) => {