@bongos/core 1.19.623 → 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.
@@ -1695,5 +1695,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1695
1695
  landed since 1.19.621 with no explicit bump. run 34322091367. (task 1002620)
1696
1696
  1.19.623 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1697
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)
1698
1700
  ---------------------------------------------------------------------------
1699
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.
@@ -475,6 +475,14 @@
475
475
  const need = (needs && Array.isArray(needs.items))
476
476
  ? needs.items.find((n) => n && n.state !== 'satisfied')
477
477
  : null;
478
+ // task 1003738: this banner is ONE box, so it still shows one need — but the
479
+ // Home inbox below is a list and must not drop the rest. computeNeeds now
480
+ // orders the action_needed bucket by an explicit weight, so `need` above is
481
+ // deliberately the most urgent rather than whichever the registry listed
482
+ // first (which was permanently the art key, hiding waiting board votes).
483
+ const actionNeeds = (needs && Array.isArray(needs.items))
484
+ ? needs.items.filter((n) => n && n.state === 'action_needed')
485
+ : [];
478
486
  const needsBanner = need
479
487
  ? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
480
488
  <strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
@@ -576,7 +584,7 @@
576
584
  }
577
585
  const homeStandingSection = document.getElementById('home-standing');
578
586
  if (homeStandingSection && !ctx.newcomerGated) homeStandingSection.hidden = false;
579
- renderSystemNotices({ rebaseTasks, need, cost });
587
+ renderSystemNotices({ rebaseTasks, need, actionNeeds, cost });
580
588
  }
581
589
 
582
590
  // The ONE string for "no preferred work chosen" — the mock's copy — on both
@@ -594,7 +602,7 @@
594
602
  // is a line item in Needs-attention, never a card). Only what awaits the
595
603
  // builder lands here — a "we cover it" note is information, and stays on the
596
604
  // full Standing view with the other two, where the banners still render.
597
- function renderSystemNotices({ rebaseTasks, need, cost }) {
605
+ function renderSystemNotices({ rebaseTasks, need, actionNeeds, cost }) {
598
606
  const box = document.getElementById('attention-system');
599
607
  if (!box) return;
600
608
  const rows = [];
@@ -610,13 +618,24 @@
610
618
  `<span class="ginbox__line">New claims are paused: ${rebaseTasks.length} confirmed ${one ? 'task' : 'tasks'} can't land yet, so ${one ? 'its' : 'their'} reward is pending. Rebase and re-ship, or release: ${refs}.</span>` +
611
619
  `</div></li>`);
612
620
  }
613
- if (need && need.state === 'action_needed') {
614
- const act = need.action && need.action.href
615
- ? `<div class="ginbox__actions"><a class="btn-ghost" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')}</a></div>`
621
+ // task 1003738: EVERY action_needed need gets its own line, not just the
622
+ // first. This surface is a list by design (the placement rule in
623
+ // modules/hall-ui/CLAUDE.md an action awaiting the builder is a line item
624
+ // in Needs-attention), but it used to render a single need, so one
625
+ // permanently-open item suppressed all the others: a builder whose image key
626
+ // was unset never saw that ideas were sitting before the board undecided.
627
+ // Ordering still decides which is loudest; it no longer decides which exist.
628
+ // The `need` fallback keeps a caller that passes only the top need working.
629
+ const noticeNeeds = (Array.isArray(actionNeeds) && actionNeeds.length)
630
+ ? actionNeeds
631
+ : (need && need.state === 'action_needed' ? [need] : []);
632
+ for (const n of noticeNeeds) {
633
+ const act = n.action && n.action.href
634
+ ? `<div class="ginbox__actions"><a class="btn-ghost" href="${escapeHtml(n.action.href)}">${escapeHtml(n.action.label || 'Set it up')}</a></div>`
616
635
  : '';
617
636
  rows.push(`<li class="ginbox__item ginbox__item--warn"><div class="ginbox__body">` +
618
- `<span class="ginbox__kind">${escapeHtml(need.title)}</span>` +
619
- `<span class="ginbox__line">${escapeHtml(need.message)}</span>` +
637
+ `<span class="ginbox__kind">${escapeHtml(n.title)}</span>` +
638
+ `<span class="ginbox__line">${escapeHtml(n.message)}</span>` +
620
639
  `</div>${act}</li>`);
621
640
  }
622
641
  if (cost && (cost.level === 'warn' || cost.level === 'over')) {
@@ -109,6 +109,52 @@ async function upsertSessionRecord(opts) {
109
109
  return upsertSessionRecordTx(pool, opts);
110
110
  }
111
111
 
112
+ // syncSessionDrachmaeEarnedTx — re-derive session_records.drachmae_earned from
113
+ // the ledger (task 1003634). Returns the synced value, or null if there was no
114
+ // row to sync (or economy is disabled).
115
+ //
116
+ // The column means "the credit_log delta attributable to this session"
117
+ // (migration 062), but the only thing that ever wrote it was the route's
118
+ // sumCreditsForTasks(), which sums rows keyed on `task_id`. The cost-plus session
119
+ // reward keys on `session_id` and leaves task_id NULL deliberately, because it
120
+ // pays for the session's real token spend rather than for any one task in it. So
121
+ // the mirror could not see it. Under reward mode 'cost-plus-only' (ADR 0146 —
122
+ // this instance's standing choice) that reward is the ONLY stream that pays, so
123
+ // the figure was structurally 0 for every session, and the hall told builders
124
+ // they had earned nothing for work they were genuinely paid for. A builder
125
+ // reported it as missing pay; the pay was never missing, this number was.
126
+ //
127
+ // It asks economy for the total over BOTH attribution keys and SETS it, rather
128
+ // than adding a delta. That is what makes it idempotent and safe to call from
129
+ // every trigger: the sum covers the row's ACCUMULATED task_ids (set-unioned
130
+ // across every upload the session has made) plus its session_id, so it already
131
+ // describes the whole session however many times it runs. Re-running can only
132
+ // land on the same number, which is why calling it on an already-paid session is
133
+ // a repair rather than a double-count.
134
+ async function syncSessionDrachmaeEarnedTx(client, builderId, sessionId) {
135
+ const reward = seams.resolveOptional('reward');
136
+ if (!reward || !sessionId) return null;
137
+ // The row's task_ids are read back rather than taken from the caller: the
138
+ // upsert set-unions them, and the two deferred triggers never had them at all.
139
+ const { rows } = await client.query(
140
+ `SELECT task_ids FROM session_records WHERE session_id = $1 AND builder_id = $2`,
141
+ [sessionId, builderId]
142
+ );
143
+ if (rows.length === 0) return null;
144
+ const ids = Array.isArray(rows[0].task_ids)
145
+ ? rows[0].task_ids.map((n) => Number(n)).filter(Number.isFinite)
146
+ : [];
147
+ const earned = await reward.sumSessionEarnings(client, builderId, ids, sessionId);
148
+ const { rows: synced } = await client.query(
149
+ `UPDATE session_records
150
+ SET drachmae_earned = $3
151
+ WHERE session_id = $1 AND builder_id = $2
152
+ RETURNING drachmae_earned`,
153
+ [sessionId, builderId, Math.trunc(Number(earned) || 0)]
154
+ );
155
+ return synced.length > 0 ? synced[0].drachmae_earned : null;
156
+ }
157
+
112
158
  // awardSessionTokenRewardTx — resolve the economy `reward` port and book the
113
159
  // idempotent cost-plus session token reward on `client`, mirroring the granted
114
160
  // drachmae onto the session_records row for observability (the authoritative
@@ -137,6 +183,19 @@ async function awardSessionTokenRewardTx(client, { builderId, sessionId, modelUs
137
183
  [sessionId, tokenReward.drachmae, builderId]
138
184
  );
139
185
  }
186
+ // Sync the earnings mirror on EVERY attempt, not only a fresh award. The
187
+ // watermark makes a re-attempt on an already-paid session a clean no-op for the
188
+ // money, and this is where that no-op earns its keep: it still repairs a row
189
+ // whose drachmae_earned was written by the old task_id-only sum. Since all
190
+ // three pay-on-land triggers funnel through here, one call site covers the
191
+ // upload-time gate, the task.shipped listener, and the state-based sweep.
192
+ const sessionDrachmaeEarned = await syncSessionDrachmaeEarnedTx(client, builderId, sessionId);
193
+ // Ride the synced figure back on the result so a caller holding a pre-sync
194
+ // record can refresh it — otherwise POST /sessions echoes the stale 0 it was
195
+ // handed even though the row is now correct.
196
+ if (tokenReward && sessionDrachmaeEarned !== null) {
197
+ tokenReward.sessionDrachmaeEarned = sessionDrachmaeEarned;
198
+ }
140
199
  return tokenReward;
141
200
  }
142
201
 
@@ -186,6 +245,12 @@ async function upsertSessionRecordWithReward(opts) {
186
245
  totalTokens: opts.totalTokens || 0,
187
246
  });
188
247
  }
248
+ // The upsert wrote the figure the route computed BEFORE this transaction
249
+ // booked anything; the award has since re-derived it. Adopt it so the
250
+ // response matches the row.
251
+ if (tokenReward && tokenReward.sessionDrachmaeEarned != null) {
252
+ record.drachmae_earned = tokenReward.sessionDrachmaeEarned;
253
+ }
189
254
  }
190
255
  return record ? { ...record, tokenReward } : null;
191
256
  });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.623",
3
+ "version": "1.19.624",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.623",
9
+ "version": "1.19.624",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.623",
3
+ "version": "1.19.624",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/backfill-session-earnings.js — repair the historical
5
+ // `session_records.drachmae_earned` mirror (task 1003634).
6
+ //
7
+ // WHAT THIS IS, and what it emphatically is NOT. It moves NO money. It writes no
8
+ // `credit_log` row, mints nothing, and cannot change any builder's balance. It
9
+ // rewrites ONE display column to agree with the ledger that already exists. That
10
+ // makes it categorically unlike its neighbours here (backfill-task-rewards.js,
11
+ // backfill-subagent-rewards.js), which mint credits and carry an ADR 0097 /
12
+ // ADR 0173 safety model to match. Read this one as a data repair.
13
+ //
14
+ // WHY IT IS NEEDED. `drachmae_earned` means "the credit_log delta attributable to
15
+ // this session" (migration 062), but the only thing that ever wrote it summed
16
+ // rows keyed on `task_id`. The cost-plus session reward (ADR 0054) keys on
17
+ // `session_id` and leaves `task_id` NULL on purpose, because it pays for the
18
+ // session's real token spend rather than for any one task in it. So the mirror
19
+ // could not see it. Under reward mode `cost-plus-only` (ADR 0146 — this
20
+ // instance's standing choice) that reward is the ONLY stream that pays, so the
21
+ // column read 0 for essentially every session, and the hall told builders they
22
+ // had earned nothing for work they had genuinely been paid for. A builder
23
+ // reported it as missing pay; the pay was never missing, this number was.
24
+ //
25
+ // WHY THE LIVE FIX DOES NOT COVER HISTORY. The code fix re-derives the figure
26
+ // whenever a reward books, and all three pay-on-land triggers now funnel through
27
+ // that. But none of them revisits a session that is already paid in full — the
28
+ // state-based sweep explicitly skips any session that already has a
29
+ // `session.token_reward` row. So already-paid history never self-heals, which is
30
+ // precisely the history a builder looks at. Hence a one-shot pass.
31
+ //
32
+ // SAFETY. DRY-RUN BY DEFAULT: with no flags it prints the plan and writes
33
+ // nothing. `--apply` performs the UPDATEs. Idempotent by construction — every
34
+ // row is SET to a value derived wholly from current ledger state, so re-running
35
+ // lands on the same numbers, and a row already correct is skipped and reported
36
+ // as such. `--builder N` scopes the pass to one builder (useful for verifying on
37
+ // a single account before the full sweep).
38
+ //
39
+ // Run: node scripts/gds/backfill-session-earnings.js [--builder N] [--limit N]
40
+ // node scripts/gds/backfill-session-earnings.js --apply
41
+
42
+ const path = require('node:path');
43
+
44
+ const credits = require('../../modules/economy/credits');
45
+ const { arg, hasFlag } = require('./cli-lib');
46
+
47
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
48
+
49
+ function loadPool() {
50
+ const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
51
+ return pool;
52
+ }
53
+
54
+ async function main() {
55
+ const apply = hasFlag('--apply');
56
+ const builderArg = arg('--builder');
57
+ const limitArg = Number(arg('--limit'));
58
+ const limit = Number.isFinite(limitArg) && limitArg > 0 ? Math.trunc(limitArg) : null;
59
+
60
+ const pool = loadPool();
61
+ const client = await pool.connect();
62
+ let changed = 0;
63
+ let alreadyCorrect = 0;
64
+ let scanned = 0;
65
+ let deltaTotal = 0;
66
+ const examples = [];
67
+
68
+ try {
69
+ const params = [];
70
+ let where = 'WHERE sr.session_id IS NOT NULL';
71
+ if (builderArg) { params.push(builderArg); where += ` AND sr.builder_id = $${params.length}`; }
72
+ let sql = `SELECT sr.session_id, sr.builder_id, sr.task_ids, sr.drachmae_earned,
73
+ b.github_login
74
+ FROM session_records sr
75
+ JOIN builders b ON b.id = sr.builder_id
76
+ ${where}
77
+ ORDER BY sr.uploaded_at DESC`;
78
+ if (limit) { params.push(limit); sql += ` LIMIT $${params.length}`; }
79
+
80
+ const { rows } = await client.query(sql, params);
81
+ scanned = rows.length;
82
+
83
+ for (const r of rows) {
84
+ const ids = Array.isArray(r.task_ids)
85
+ ? r.task_ids.map((n) => Number(n)).filter(Number.isFinite)
86
+ : [];
87
+ // The same two-key sum the live path uses — one definition, so a repaired
88
+ // row and a freshly-synced row can never disagree.
89
+ const truth = await credits.sumSessionEarnings(client, r.builder_id, ids, r.session_id);
90
+ const was = Number(r.drachmae_earned) || 0;
91
+ if (truth === was) { alreadyCorrect++; continue; }
92
+ changed++;
93
+ deltaTotal += (truth - was);
94
+ if (examples.length < 15) {
95
+ examples.push(` ${String(r.github_login).padEnd(18)} ${r.session_id.slice(0, 8)} ${was} → ${truth}`);
96
+ }
97
+ if (apply) {
98
+ await client.query(
99
+ `UPDATE session_records SET drachmae_earned = $3
100
+ WHERE session_id = $1 AND builder_id = $2`,
101
+ [r.session_id, r.builder_id, truth]
102
+ );
103
+ }
104
+ }
105
+ } finally {
106
+ client.release();
107
+ await pool.end().catch(() => {});
108
+ }
109
+
110
+ console.log(`\nsession-earnings backfill — ${apply ? 'APPLIED' : 'DRY RUN (no writes)'}`);
111
+ console.log(` sessions scanned ${scanned}`);
112
+ console.log(` already correct ${alreadyCorrect}`);
113
+ console.log(` ${apply ? 'repaired' : 'would repair'}${apply ? ' ' : ' '}${changed}`);
114
+ console.log(` net mirror change ${deltaTotal >= 0 ? '+' : ''}${deltaTotal} drachmae of DISPLAY (no money moved)`);
115
+ if (examples.length > 0) {
116
+ console.log(`\n sample rows:\n${examples.join('\n')}`);
117
+ }
118
+ if (!apply && changed > 0) {
119
+ console.log(`\n re-run with --apply to write these.`);
120
+ }
121
+ }
122
+
123
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -106,6 +106,13 @@ const INTEGRATION = new Set([
106
106
  // parses this set out of the source, so a quoted word in a comment reads as an
107
107
  // entry — the same reason the copy_desk_flags_db note above is worded that way.)
108
108
  'idea_credit_streams_db',
109
+ // task 1003634: the session earnings mirror against real SQL. The defect was
110
+ // never arithmetic — it was which credit_log rows a WHERE clause can reach, so
111
+ // a stubbed client would answer exactly the assumption under test. Here the
112
+ // two-key predicate meets a real planner, and the first test asserts the OLD
113
+ // predicate still returns 0 so the bug itself stays executable. Same
114
+ // always-rolled-back transaction discipline as the entry above.
115
+ 'session_earnings_mirror_db',
109
116
  // RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
110
117
  // 1003091. A curated set keyed by a stem that no longer exists silently stops
111
118
  // covering its file: both of these dropped OUT of the integration set and INTO the
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.623'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.624'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -209,5 +209,127 @@ t('the /me aggregator feeds computeNeeds the founder facts under their contract
209
209
  assert.match(src, /getFoundingBuilder\(\)/, 'me.js must resolve the founder row');
210
210
  });
211
211
 
212
+ // ---- two needs at once (task 1003738) --------------------------------------
213
+ //
214
+ // The defect: computeNeeds sorted by state alone with a stable sort, so within
215
+ // the action_needed bucket the NEEDS array order decided everything — and
216
+ // board_votes is listed fourth, behind art_key. A builder with an unset image
217
+ // key therefore never saw a waiting board vote, on either surface, because both
218
+ // renderers took "the first one". The fix is two-part and both parts are pinned
219
+ // here: an explicit priority inside the bucket, and a LIST surface that renders
220
+ // every action_needed item instead of only the loudest.
221
+ console.log('two simultaneous action_needed needs (task 1003738):');
222
+
223
+ // art_key action_needed (archon, no own key) + board_votes action_needed.
224
+ const TWO = {
225
+ rank: 'archon',
226
+ ownKeySet: false,
227
+ pendingBoardVotes: { count: 2, first_item_id: 12 },
228
+ };
229
+
230
+ t('both needs are action_needed — the fixture really does collide', () => {
231
+ const r = needs.computeNeeds(TWO);
232
+ const ids = r.items.filter((n) => n.state === 'action_needed').map((n) => n.id);
233
+ assert.ok(ids.includes('art_key'), 'art_key must be action_needed here');
234
+ assert.ok(ids.includes('board_votes'), 'board_votes must be action_needed here');
235
+ assert.equal(r.action_needed_count, 2);
236
+ });
237
+
238
+ t('the board vote outranks the art key — someone else is blocked on it', () => {
239
+ const r = needs.computeNeeds(TWO);
240
+ assert.equal(r.items[0].id, 'board_votes',
241
+ 'a need others are blocked on must lead; registry order must not decide');
242
+ });
243
+
244
+ t('priority is NOT severity — severity would have kept the bug', () => {
245
+ // The trap worth pinning: art_key is severity 'action' and board_votes is
246
+ // 'info', so ordering the bucket by severity would have left the art key
247
+ // first and the board vote hidden. If someone "simplifies" the weight map
248
+ // into a severity sort, this fails.
249
+ const r = needs.computeNeeds(TWO);
250
+ const art_ = r.items.find((n) => n.id === 'art_key');
251
+ const board = r.items.find((n) => n.id === 'board_votes');
252
+ assert.equal(art_.severity, 'action');
253
+ assert.equal(board.severity, 'info');
254
+ assert.ok(needs.NEED_PRIORITY.board_votes < needs.NEED_PRIORITY.art_key,
255
+ 'the weight map, not severity, is what orders the bucket');
256
+ });
257
+
258
+ t('an unweighted need sorts last in its bucket, never first', () => {
259
+ // Appending to NEEDS without adding a weight must not silently take the lead.
260
+ const items = [{ id: 'brand_new', state: 'action_needed' }, { id: 'board_votes', state: 'action_needed' }];
261
+ const order = { action_needed: 0, covered: 1, satisfied: 2 };
262
+ const weight = (n) => needs.NEED_PRIORITY[n.id] ?? 99;
263
+ items.sort((a, b) => ((order[a.state] ?? 9) - (order[b.state] ?? 9)) || (weight(a) - weight(b)));
264
+ assert.equal(items[0].id, 'board_votes');
265
+ });
266
+
267
+ t('the founding welcome still leads during the grace', () => {
268
+ // The weight tie-break must not demote the welcome: before this task it was
269
+ // first only because unshift + a stable sort put it there.
270
+ const r = needs.computeNeeds({
271
+ ...TWO, isFoundingOwner: true, founderCreatedAt: new Date().toISOString(), projectName: 'Mercury',
272
+ });
273
+ assert.equal(r.items[0].id, 'founding_welcome');
274
+ assert.equal(r.has_action_needed, false);
275
+ });
276
+
277
+ // ---- the rendered output ----------------------------------------------------
278
+ //
279
+ // renderSystemNotices lives inside hall-render.js's IIFE, so it cannot be
280
+ // imported — but it only touches `document`, `escapeHtml` and `window`, so the
281
+ // real shipped function is sliced out and RUN here (the repo's "run it rather
282
+ // than grep it" preference) rather than asserted against by regex. A grep would
283
+ // pass on a comment; this fails if the board vote is not in the HTML.
284
+ function runSystemNotices(args) {
285
+ const src = readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-render.js'), 'utf8');
286
+ const start = src.indexOf('function renderSystemNotices');
287
+ assert.ok(start > 0, 'renderSystemNotices must still exist in hall-render.js');
288
+ // Anchor on the BODY brace, not the first '{' — the signature destructures
289
+ // its argument, so the first brace closes the parameter list.
290
+ const argsEnd = src.indexOf(') {', start);
291
+ assert.ok(argsEnd > start, 'renderSystemNotices signature changed shape');
292
+ let i = src.indexOf('{', argsEnd);
293
+ let depth = 0;
294
+ let end = -1;
295
+ for (; i < src.length; i++) {
296
+ if (src[i] === '{') depth++;
297
+ else if (src[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
298
+ }
299
+ assert.ok(end > start, 'could not slice renderSystemNotices');
300
+ const box = { innerHTML: '' };
301
+ const doc = { getElementById: (id) => (id === 'attention-system' ? box : null) };
302
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
303
+ const body = src.slice(start, end);
304
+ const make = new Function('document', 'escapeHtml', 'window', `${body}\nreturn renderSystemNotices;`);
305
+ make(doc, esc, {})(args);
306
+ return box.innerHTML;
307
+ }
308
+
309
+ t('THE BUG: with the art key also open, the board vote is still rendered', () => {
310
+ const r = needs.computeNeeds(TWO);
311
+ const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
312
+ const html = runSystemNotices({
313
+ rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null,
314
+ });
315
+ assert.match(html, /waiting on your vote/i,
316
+ 'the board vote must be reachable in the rendered output, not suppressed');
317
+ assert.match(html, /art coverage has ended/i,
318
+ 'and the art key must not be dropped either — the list shows both');
319
+ assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 2, 'one line per waiting need');
320
+ });
321
+
322
+ t('a single action_needed need still renders exactly one line', () => {
323
+ const r = needs.computeNeeds({ rank: 'thetes', ownKeySet: false });
324
+ const actionNeeds = r.items.filter((n) => n.state === 'action_needed');
325
+ const html = runSystemNotices({ rebaseTasks: [], need: actionNeeds[0], actionNeeds, cost: null });
326
+ assert.equal((html.match(/<li class="ginbox__item/g) || []).length, 1);
327
+ });
328
+
329
+ t('nothing waiting renders nothing at all', () => {
330
+ const html = runSystemNotices({ rebaseTasks: [], need: null, actionNeeds: [], cost: null });
331
+ assert.equal(html, '');
332
+ });
333
+
212
334
  console.log(`\n${passed} passed, ${failed} failed`);
213
335
  process.exit(failed ? 1 : 0);