@bongos/core 1.19.622 → 1.19.624

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 (36) hide show
  1. package/.bongos-core.json +70 -35
  2. package/docs/copy-inventory.md +166 -158
  3. package/docs/copy-registry.json +286 -192
  4. package/docs/module-api-changelog.md +4 -0
  5. package/modules/builder-settings/builder-needs.js +41 -4
  6. package/modules/economy/credits.js +38 -0
  7. package/modules/economy/reward.js +5 -0
  8. package/modules/hall-ui/public/board-room.css +83 -0
  9. package/modules/hall-ui/public/board-room.html +70 -0
  10. package/modules/hall-ui/public/board-room.js +517 -0
  11. package/modules/hall-ui/public/board-room.states.json +104 -0
  12. package/modules/hall-ui/public/government.css +10 -57
  13. package/modules/hall-ui/public/government.html +10 -12
  14. package/modules/hall-ui/public/government.js +119 -367
  15. package/modules/hall-ui/public/hall-render.js +26 -7
  16. package/modules/hall-ui/public/palette.js +5 -2
  17. package/modules/hall-ui/public/shell.js +16 -0
  18. package/modules/sessions/db.js +65 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/gds/backfill-session-earnings.js +123 -0
  22. package/scripts/gds/fitness-checks-vocabulary.js +7 -0
  23. package/scripts/gds/run-unit-tests.js +7 -0
  24. package/src/bongos/auth.js +27 -0
  25. package/src/bongos/serve-internal.js +21 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/auth_resolve_failure.mjs +6 -5
  28. package/tests/builder_needs.mjs +122 -0
  29. package/tests/government_board_hash_redirect.mjs +129 -0
  30. package/tests/government_board_room_copy.mjs +41 -15
  31. package/tests/government_board_vote_ui.mjs +5 -1
  32. package/tests/hall_nav.mjs +5 -1
  33. package/tests/hall_page_gate_map.mjs +11 -0
  34. package/tests/hall_palette.mjs +9 -3
  35. package/tests/rank_tier_single_source.mjs +8 -0
  36. package/tests/session_earnings_mirror_db.mjs +250 -0
@@ -1693,5 +1693,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1693
1693
  landed since 1.19.620 with no explicit bump. run 34321546662. (task 1002620)
1694
1694
  1.19.622 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1695
1695
  landed since 1.19.621 with no explicit bump. run 34322091367. (task 1002620)
1696
+ 1.19.623 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1697
+ landed since 1.19.622 with no explicit bump. run 34323141703. (task 1002620)
1698
+ 1.19.624 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1699
+ landed since 1.19.623 with no explicit bump. run 34326009063. (task 1002620)
1696
1700
  ---------------------------------------------------------------------------
1697
1701
  ```
@@ -219,6 +219,36 @@ function mingleIntroductionsNeed(ctx) {
219
219
 
220
220
  const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
221
221
 
222
+ // Priority WITHIN a state bucket (task 1003738). computeNeeds used to sort by
223
+ // state alone with a stable sort, so the NEEDS array order silently decided
224
+ // which item a single-slot renderer showed. board_votes sits fourth, behind
225
+ // art_key — so any builder with an unset image key never saw a waiting board
226
+ // vote, while both renderers' comments claimed they showed "the highest-priority"
227
+ // need. Within a bucket there was no such thing.
228
+ //
229
+ // NOT keyed on `severity`, which is the obvious-looking choice and the wrong
230
+ // one: art_key is severity 'action' and board_votes is 'info', so ordering by
231
+ // severity would have PRESERVED the exact bug this fixes. The rule here is who
232
+ // is waiting:
233
+ //
234
+ // A NEED SOMEONE ELSE IS BLOCKED ON OUTRANKS A NEED THAT ONLY AFFECTS YOU.
235
+ //
236
+ // A sitting cannot be decided until a member votes, and its window can expire;
237
+ // an unset art key stops only your own art, whenever you next want it.
238
+ //
239
+ // Lower number = louder. An id missing from this map sorts last, so appending to
240
+ // NEEDS without a weight is safe (it just lands at the back of its bucket).
241
+ // founding_welcome is deliberately negative: during the founding grace the
242
+ // roster leads with the welcome, which is what the grace is for.
243
+ const NEED_PRIORITY = {
244
+ founding_welcome: -1,
245
+ board_votes: 0,
246
+ help_requests: 1,
247
+ art_key: 2,
248
+ task_recommendations: 3,
249
+ mingle_introductions: 4,
250
+ };
251
+
222
252
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
223
253
  //
224
254
  // A freshly-provisioned instance used to greet its founding owner with "your
@@ -265,9 +295,14 @@ function foundingWelcomeNeed(ctx) {
265
295
  // functions read (see each need's doc). Returns:
266
296
  // { items: Need[], action_needed_count, has_action_needed }
267
297
  // items is ordered: action_needed first (loudest), then covered, then satisfied,
268
- // so any renderer that takes "the first one" gets the most urgent. During the
269
- // founding grace nothing is action_needed, so the welcome (prepended before the
270
- // stable sort) is what that renderer shows.
298
+ // and WITHIN each bucket by NEED_PRIORITY above so a renderer that takes "the
299
+ // first one" really does get the most urgent, which before task 1003738 it did
300
+ // not (it got whichever need NEEDS happened to list first). During the founding
301
+ // grace nothing is action_needed, so the welcome is what that renderer shows.
302
+ //
303
+ // A renderer that can show a LIST should render every action_needed item rather
304
+ // than the first: ordering decides emphasis, it does not make the rest safe to
305
+ // drop. Home's "From the system" inbox does exactly that.
271
306
  function computeNeeds(ctx = {}) {
272
307
  const grace = foundingGraceActive(ctx);
273
308
  let items = NEEDS.map((fn) => fn(ctx)).filter(Boolean);
@@ -282,7 +317,8 @@ function computeNeeds(ctx = {}) {
282
317
  items.unshift(foundingWelcomeNeed(ctx));
283
318
  }
284
319
  const order = { action_needed: 0, covered: 1, satisfied: 2 };
285
- items.sort((a, b) => (order[a.state] ?? 9) - (order[b.state] ?? 9));
320
+ const weight = (n) => NEED_PRIORITY[n.id] ?? 99;
321
+ items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
286
322
  const actionNeeded = items.filter((n) => n.state === 'action_needed');
287
323
  return {
288
324
  items,
@@ -299,4 +335,5 @@ module.exports = {
299
335
  foundingGraceActive,
300
336
  FOUNDING_GRACE_DAYS,
301
337
  SETTINGS_PATH,
338
+ NEED_PRIORITY,
302
339
  };
@@ -931,6 +931,43 @@ async function sumCreditsForTasks(builderId, taskIds) {
931
931
  return rows[0] ? rows[0].total : 0;
932
932
  }
933
933
 
934
+ // sumSessionEarnings — what ONE session actually earned its builder, across BOTH
935
+ // of the ledger's attribution keys. The authoritative answer to the question
936
+ // session_records.drachmae_earned exists to mirror.
937
+ //
938
+ // Why it has to be two keys: credit_log rows reach a builder by two different
939
+ // routes, and each stream picks exactly one. The per-task streams (the confirm
940
+ // credit, the idea slices) key on `task_id` and leave session_id NULL. The
941
+ // cost-plus session reward (ADR 0054) is the mirror image — it keys on
942
+ // `session_id` and leaves task_id NULL deliberately, because it pays for the
943
+ // SESSION's real token spend, not for any one task in it.
944
+ //
945
+ // sumCreditsForTasks() above sees only the first route. That was survivable while
946
+ // every instance paid both streams, but under reward mode 'cost-plus-only'
947
+ // (ADR 0146 — this instance's standing choice) the session reward is the ONLY
948
+ // stream that pays, so a task_id-keyed sum is structurally always 0 and the hall
949
+ // told every builder they had earned nothing for work they were genuinely paid
950
+ // for (task 1003634). A builder reported it as missing pay; the money was never
951
+ // missing, the mirror was.
952
+ //
953
+ // `executor` is a Pool or a tx client, so the caller can ask INSIDE the same
954
+ // transaction that just booked the reward and see it. The OR is one predicate over
955
+ // one scan, so a row matching both keys is summed once, not twice — the figure is
956
+ // a pure function of ledger state and can be re-derived any number of times.
957
+ async function sumSessionEarnings(executor, builderId, taskIds, sessionId) {
958
+ const ids = Array.isArray(taskIds) ? taskIds.map(Number).filter(Number.isFinite) : [];
959
+ const sid = sessionId == null ? null : String(sessionId);
960
+ if (ids.length === 0 && !sid) return 0;
961
+ const { rows } = await executor.query(
962
+ `SELECT COALESCE(SUM(delta), 0)::int AS total
963
+ FROM credit_log
964
+ WHERE builder_id = $1
965
+ AND (task_id = ANY($2::bigint[]) OR ($3::text IS NOT NULL AND session_id = $3))`,
966
+ [builderId, ids, sid]
967
+ );
968
+ return rows[0] ? rows[0].total : 0;
969
+ }
970
+
934
971
  // The three ideator streams (ADR 0172) each book under a reason key that EMBEDS
935
972
  // the idea (and, for stream 1, the task) — that string IS the idempotency
936
973
  // predicate, which is why it can't be a bare constant:
@@ -1038,6 +1075,7 @@ module.exports = {
1038
1075
  netNegativeBonusAmount,
1039
1076
  awardNetNegativeBonus,
1040
1077
  sumCreditsForTasks,
1078
+ sumSessionEarnings,
1041
1079
  // the ledger read surface (task 1003484, blocker 1000127)
1042
1080
  creditLedger,
1043
1081
  IDEA_STREAM_REASON_PREFIXES,
@@ -73,6 +73,11 @@ module.exports = {
73
73
  getBuilderAchievements: (...a) => achievements.getBuilderAchievements(...a),
74
74
  getLockedAchievementsProgress: (...a) => achievements.getLockedAchievementsProgress(...a),
75
75
  sumCreditsForTasks: (...a) => credits.sumCreditsForTasks(...a),
76
+ // The two-key session earnings sum (task 1003634) — transaction-participant:
77
+ // the sessions module passes its tx `client` so it reads the cost-plus reward
78
+ // its own transaction just booked. On the port because credit_log is economy's
79
+ // table and sessions may only cross the wall through here (ADR 0083 / 0093 §2).
80
+ sumSessionEarnings: (...a) => credits.sumSessionEarnings(...a),
76
81
  // The per-row ledger read (task 1003484). On the port because the surface core
77
82
  // most wants it for is a HALL view — "what did this idea earn me" — and a core
78
83
  // route must reach economy through here, never by importing the module.
@@ -0,0 +1,83 @@
1
+ /* board-room.css — Page-local styles for the Board Room (/builders/board-room).
2
+
3
+ Carved out of government.css by ADR 0266, with the room itself: these rules
4
+ were the `#board-room` tab's and nothing on the government page reads them
5
+ any more, so leaving them there would have been dead weight on a sheet two
6
+ other rooms still load. What lives here is what only this page has — the
7
+ sitting card in task 1003270's order (title, ballot, the idea, the
8
+ apparatus) and the ballot controls. The panel is the shell's .scroll and the
9
+ rows are the oversight family's (oversight.css); the ?v= cache-bust is
10
+ stamped from this file's content hash at boot (src/asset-version.js).
11
+
12
+ TWO RULES ARE DUPLICATED FROM government.css, KNOWINGLY. `.gov-grid-note` is
13
+ the government family's framing-line voice, and the sitting card spends it in
14
+ three places (decidedByHtml, the own-idea note, the objection hint) — while
15
+ the Constitution panel still spends it over there. Copying two short rules is
16
+ the cheaper of two bad options: the alternative is this page loading a
17
+ stylesheet named for a different room in order to inherit them, which is what
18
+ the first cut of this carve did and what the review called out. The NAMES stay
19
+ `gov-*` because board-room.js emits them and
20
+ tests/government_board_room_copy.mjs reads them as literal markup. */
21
+
22
+ /* The framing line under a heading — the family voice, duplicated per the header
23
+ note above rather than inherited across pages. */
24
+ .gov-grid-note { margin: 0 0 14px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); }
25
+ .gov-grid-note strong { color: var(--ink); font-weight: 500; }
26
+ .gov-grid-note code { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); overflow-wrap: anywhere; }
27
+
28
+ /* "your vote" beside the reader's own row in the open ballot. */
29
+ .gov-held-note { font-size: 12.5px; color: var(--ok); white-space: nowrap; }
30
+
31
+ /* ---- the Board Room: one sitting, one panel -------------------------------------
32
+ The card order is task 1003270's (title, ballot, the idea, the apparatus); the
33
+ surface is the shell's .scroll (task 1003469). The subject of the vote is the
34
+ panel heading; the ballot sits directly under it; the idea is prose; the
35
+ constitutional apparatus stays one disclosure away with the tally on its summary. */
36
+ .gov-item__head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; }
37
+ .gov-item__title {
38
+ margin: 0 0 8px;
39
+ min-width: 0;
40
+ font-family: var(--font-display);
41
+ font-weight: 300;
42
+ font-size: 21px;
43
+ line-height: 1.15;
44
+ letter-spacing: -0.03em;
45
+ color: var(--ink);
46
+ text-shadow: var(--type-halo);
47
+ overflow-wrap: anywhere;
48
+ }
49
+ .gov-item__title a { color: inherit; text-decoration: none; }
50
+ .gov-item__title a:hover { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 3px; }
51
+ .gov-item__title .fact-pill { vertical-align: middle; margin-left: 10px; text-shadow: none; }
52
+ .gov-item__state { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
53
+ .gov-item__amend { margin: 0 0 12px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); overflow-wrap: anywhere; }
54
+ .gov-item__act { margin: 8px 0 4px; }
55
+ .gov-item__cast { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
56
+ .gov-item__meta { margin: var(--sp-sm) 0 0; border-top: var(--line) solid var(--rule-soft); }
57
+ .gov-item__summary { padding: 12px 0 0; min-height: 24px; cursor: pointer; font-size: 12.5px; color: var(--ink-soft); list-style-position: inside; }
58
+ .gov-item__summary:hover { color: var(--ink); }
59
+ .gov-item__metabody { padding: 12px 0 0; }
60
+ .gov-item__metabody .gov-grid-note { margin: 0 0 8px; }
61
+ .gov-item__metabody .gov-grid-note:last-child { margin-bottom: 0; }
62
+ .gov-item__metabody .ov-rows { margin-top: 12px; }
63
+ /* The head over the closed sittings sits on the ground between panels, so it
64
+ carries the air a panel would. */
65
+ .gov-board__divider { margin: var(--sp-md) 0 var(--sp-sm); }
66
+
67
+ /* ---- the ballot controls ------------------------------------------------------ */
68
+ /* The two ballot controls sit on one row; the objection disclosure takes the
69
+ whole row only once it is open, so its form drops under both buttons. */
70
+ .gov-vote { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
71
+ .gov-vote > .gov-grid-note { flex: 1 1 100%; margin: 0; }
72
+ .gov-vote__no { flex: 0 1 auto; }
73
+ .gov-vote__no[open] { flex: 1 1 100%; }
74
+ /* The disclosure IS the secondary button, so its marker must go — otherwise the
75
+ triangle sits inside the pill. */
76
+ .gov-vote__no > summary { display: inline-flex; cursor: pointer; }
77
+ .gov-vote__no > summary::marker { content: ''; }
78
+ .gov-vote__no > summary::-webkit-details-marker { display: none; }
79
+ .gov-vote__no > *:not(summary) { margin-top: 10px; }
80
+ .gov-vote__no textarea { display: block; width: 100%; max-width: var(--measure); box-sizing: border-box; }
81
+ .gov-vote__sections { display: flex; flex-wrap: wrap; gap: 6px 16px; }
82
+ .gov-vote__section { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; font-size: 13px; color: var(--ink); cursor: pointer; }
83
+ .gov-vote__section input { width: 16px; height: 16px; margin: 0; }
@@ -0,0 +1,70 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Board Room — {{worldName}}</title>
7
+ <meta name="description" content="The Board Room — where a Full Idea that cleared the completeness grade is ratified, and where the constitution itself is amended.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <link rel="stylesheet" href="/builders/oversight.css">
13
+ <!-- The room's own sheet: the sitting card and the ballot controls moved here
14
+ out of government.css with the room (ADR 0266), so neither page carries the
15
+ other's rules. board-room.css's header records the two family rules it
16
+ duplicates rather than inherit across pages. -->
17
+ <link rel="stylesheet" href="/builders/board-room.css">
18
+ </head>
19
+ <body data-page="board-room" data-page-title="Board Room">
20
+
21
+ <div class="app">
22
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
23
+ <div class="app-scrim" id="app-scrim" hidden></div>
24
+ <div class="app-body">
25
+ <header class="app-topbar" id="app-topbar"></header>
26
+ <div class="app-content">
27
+ <!-- The board page head (task 1003307): the LABEL voice, not a display
28
+ line, because on a working surface the page's own name is the least
29
+ interesting thing on it. -->
30
+ <div class="page-head page-head--board">
31
+ <h1 class="page-head__h">Board Room</h1>
32
+ <p class="page-head__sub" id="board-sub">Loading…</p>
33
+ </div>
34
+
35
+ <main id="hall-main">
36
+ <!-- task 1003469: the sealed state is a panel with a heading, not a bare
37
+ line on the ground. board-room.js writes the floor into the lede from
38
+ the pack's rank label, so the copy cannot drift. Unlike its
39
+ neighbours this page's floor is the VOTE atom (xenos), so the sealed
40
+ state is what a signed-out or ineligible visitor sees, not a rank
41
+ notice — see ADR 0266. -->
42
+ <section class="scroll" id="board-sealed" aria-labelledby="board-sealed-h" hidden>
43
+ <h2 class="scroll__h" id="board-sealed-h">Restricted</h2>
44
+ <p class="scroll__lede" id="board-sealed-body">This page is for members of the board.</p>
45
+ </section>
46
+
47
+ <!-- A sitting IS the panel (task 1003469), so the room itself is bare
48
+ ground: the lede sits over the sittings, each of which is a .scroll.
49
+ The <h1> above is the room's name, so there is no second heading. -->
50
+ <section id="board-body" hidden>
51
+ <p class="gov-grid-note">Read an idea, then decide. An objection needs a reason and the section it faults.</p>
52
+ <div id="gov-board"><span class="kit-skel"></span></div>
53
+ </section>
54
+ </main>
55
+
56
+ <footer class="app-foot" id="app-foot"></footer>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
62
+
63
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
64
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
65
+ <script src="/builders/palette.js" defer></script>
66
+ <script src="/builders/dom-utils.js?v=2026-06-16-board"></script>
67
+ <script src="/builders/hall-kit.js"></script>
68
+ <script src="/builders/board-room.js?v=2026-09-09-board-carve"></script>
69
+ </body>
70
+ </html>