@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.
- package/.bongos-core.json +521 -281
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-exit/SKILL.md +2 -2
- package/.claude/skills/builder-redteam/SKILL.md +3 -3
- package/.claude/skills/builder-setup/SKILL.md +2 -2
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/dev/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +2 -2
- package/.claude/skills/ideate/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +3 -0
- package/.claude/skills/paint/SKILL.md +1 -1
- package/GOVERNANCE.md +1 -1
- package/README.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0001-stack-choice-single-vps.md +1 -1
- package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
- package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
- package/docs/adr/0030-builders-hall-live-updates.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
- package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
- package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
- package/docs/adr/0057-container-cost-ledger.md +3 -3
- package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
- package/docs/adr/0096-require-reward-before-workable.md +9 -9
- package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
- package/docs/adr/0100-two-repo-instance-model.md +1 -1
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
- package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
- package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
- package/docs/adr/README.md +9 -6
- package/docs/api/openapi.json +65 -26
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +5 -4
- package/docs/canonical-permissions.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +18 -17
- package/docs/handoff-template.md +2 -2
- package/docs/modules-contract.md +6 -1
- package/docs/onboarding/diagrams/README.md +7 -7
- package/docs/onboarding/drachmae-and-karma.md +18 -18
- package/docs/onboarding/primer.md +9 -9
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/builder-box-lifecycle.md +2 -2
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/ops-gotchas.md +1 -1
- package/docs/recipes/private-npm-distribution.md +17 -0
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/upgrading-the-core.md +55 -0
- package/docs/research/cursor-competitive-analysis.md +1 -1
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
- package/modules/discord/module.json +2 -1
- package/modules/economy/achievements.js +9 -1
- package/modules/economy/credits.js +10 -1
- package/modules/hall-ui/public/builders.js +137 -620
- package/modules/hall-ui/public/goal-inbox.js +232 -0
- package/modules/hall-ui/public/goals-page.js +4 -1
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/hall-kit.js +474 -0
- package/modules/hall-ui/public/hall-widgets.js +14 -5
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/public/instances-panel.js +24 -3
- package/modules/hall-ui/public/new-project-wizard.js +7 -5
- package/modules/hall-ui/public/style.css +168 -0
- package/modules/hall-ui/public/work.css +194 -0
- package/modules/hall-ui/public/work.html +46 -374
- package/modules/hall-ui/public/work.js +137 -102
- package/modules/lifecycle/criterion-suggest.js +80 -0
- package/modules/lifecycle/db.js +42 -5
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/tasks.js +21 -0
- package/modules/provisioning/CLAUDE.md +15 -0
- package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
- package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
- package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
- package/modules/provisioning/module.json +17 -0
- package/modules/provisioning/onboard-plan.js +37 -0
- package/modules/provisioning/provisioning.js +539 -0
- package/modules/provisioning/rate-limit.js +35 -0
- package/modules/provisioning/routes/provisioning.js +607 -0
- package/modules/provisioning/tests/provisioning.mjs +232 -0
- package/modules/provisioning/ui/.gitkeep +0 -0
- package/modules/security/secret-scrub.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +7 -2
- package/scripts/discord/README.md +1 -1
- package/scripts/gds/claim.js +98 -48
- package/scripts/gds/claude-materialize.js +30 -9
- package/scripts/gds/cutover-codemod.js +241 -0
- package/scripts/gds/fitness.js +54 -0
- package/scripts/gds/init.js +59 -3
- package/scripts/gds/lint-infra-exec.js +70 -0
- package/scripts/gds/lint-workflow-exec.js +63 -0
- package/scripts/gds/mirror-lineage-check.js +14 -5
- package/scripts/gds/mirror-redact.js +8 -0
- package/scripts/gds/onboard.js +4 -0
- package/scripts/gds/package-core.js +18 -3
- package/scripts/gds/publish-manifest.js +60 -2
- package/scripts/gds/run-routine.js +51 -13
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/update-channel.js +186 -0
- package/scripts/gds/upgrade.js +116 -9
- package/scripts/hall-preview/README.md +103 -0
- package/scripts/hall-preview/fixtures/access-requests.json +1 -0
- package/scripts/hall-preview/fixtures/achievements.json +76 -0
- package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
- package/scripts/hall-preview/fixtures/audit-log.json +1 -0
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
- package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
- package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
- package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
- package/scripts/hall-preview/fixtures/goals.json +502 -0
- package/scripts/hall-preview/fixtures/goals__25.json +96 -0
- package/scripts/hall-preview/fixtures/goals__26.json +67 -0
- package/scripts/hall-preview/fixtures/goals__3.json +103 -0
- package/scripts/hall-preview/fixtures/goals__35.json +49 -0
- package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
- package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
- package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
- package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
- package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
- package/scripts/hall-preview/fixtures/inbox.json +330 -0
- package/scripts/hall-preview/fixtures/instance.json +124 -0
- package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
- package/scripts/hall-preview/fixtures/me.json +476 -0
- package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
- package/scripts/hall-preview/fixtures/override-requests.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
- package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
- package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
- package/scripts/hall-preview/fixtures/public__grades.json +50 -0
- package/scripts/hall-preview/fixtures/public__progress.json +124 -0
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
- package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
- package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
- package/scripts/hall-preview/fixtures/security__docs.json +1 -0
- package/scripts/hall-preview/fixtures/security__reports.json +1 -0
- package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
- package/scripts/hall-preview/fixtures/tasks.json +2420 -0
- package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
- package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
- package/scripts/hall-preview/fixtures/versions.json +89 -0
- package/scripts/hall-preview/refresh-fixtures.js +77 -0
- package/scripts/hall-preview/server.js +178 -0
- package/scripts/migrate.sh +29 -4
- package/src/bongos/auth.js +14 -1
- package/src/bongos/optimizer.js +37 -2
- package/src/bongos/serve-internal.js +10 -1
- package/src/branding.js +1 -0
- package/src/module-api.js +12 -1
- package/src/module-loader/loader.js +36 -4
- package/src/module-loader/manifest-schema.js +16 -1
- package/tests/branding.mjs +11 -0
- package/tests/claim_error_surface.mjs +107 -0
- package/tests/claude_materialize.mjs +20 -0
- package/tests/consumer_layout_boot.mjs +302 -0
- package/tests/criterion_suggest.mjs +106 -0
- package/tests/cutover_codemod.mjs +184 -0
- package/tests/device_flow_start.mjs +25 -2
- package/tests/hook_core_resolve.mjs +125 -0
- package/tests/infra_exec_paths.mjs +104 -0
- package/tests/init.mjs +47 -0
- package/tests/migrate_root_resolution.mjs +97 -0
- package/tests/mirror_lineage_proof.mjs +14 -0
- package/tests/module_loader.mjs +42 -0
- package/tests/module_manifest.mjs +19 -0
- package/tests/onboard.mjs +13 -0
- package/tests/package_core.mjs +8 -0
- package/tests/provisioning_github_app_routes.mjs +15 -0
- package/tests/publish_manifest.mjs +52 -1
- package/tests/run_routine.mjs +26 -1
- package/tests/run_routine_two_root.mjs +77 -0
- package/tests/scrubber_corpus.mjs +17 -0
- package/tests/update_channel.mjs +186 -0
- package/tests/upgrade.mjs +131 -0
- package/tests/workflow_exec_paths.mjs +98 -0
- package/docs/design/container-exterior-tiles.md +0 -49
- package/docs/design/container-exterior.md +0 -81
- package/docs/design/container-interior.md +0 -77
- package/docs/design/world-map-scroll.md +0 -41
- package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
- package/docs/recipes/cloudbongos-staging-gate.md +0 -139
- package/docs/recipes/cloudbongos-standup.md +0 -111
- package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
- package/docs/recipes/parallel-load-test.md +0 -32
- package/docs/recipes/pixel-art-pipeline.md +0 -411
- package/docs/recipes/staging-data.md +0 -50
- package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
- package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
- package/migrations/instance/110_medusa_planning_seed.sql +0 -70
- package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
- package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
- package/scripts/gds/art-stage.js +0 -152
- package/server.js +0 -212
- package/tests/art_key.mjs +0 -121
- package/tests/day_cycle.mjs +0 -197
- package/tests/snapshot_picker.mjs +0 -233
- package/tests/sprite_facing.mjs +0 -35
- package/tests/terrain_picker.mjs +0 -298
- package/tests/turn_in_place.mjs +0 -280
- 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
|
-
|
|
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
|
|
1873
|
-
// `lb-sort-<key>`. The handler walks aria-sort across all
|
|
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(
|
|
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) => {
|