@bongos/core 1.21.1 → 1.21.2

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.
@@ -429,9 +429,21 @@ async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }
429
429
  if (rowCount > 0) credited += p.delta;
430
430
  booked.push({ ...p, booked: rowCount > 0 });
431
431
  }
432
+ // task 1004438: an idea credit is an ideator's moment (First Thought Ignited,
433
+ // First Full Idea, Ratified by the Board, A Thought That Built Ten), and it is
434
+ // booked inside someone else's ship, so the shipper's reconcile never reaches
435
+ // the payee. Every idea stream books through here; each payee just paid is
436
+ // reconciled, and the hook can never fail the payment.
437
+ for (const p of booked) {
438
+ if (p.booked) await craftAchievements().reconcileAfterCraftCredit(client, p.builderId, { taskId });
439
+ }
432
440
  return { credited, payees: booked };
433
441
  }
434
442
 
443
+ // Lazy: achievements.js sits beside this file and requires nothing of it, but a
444
+ // load-time require would make the two files' load order matter for no reason.
445
+ function craftAchievements() { return require('./achievements'); }
446
+
435
447
  // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
436
448
  // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
437
449
  // of the reward port — the caller passes its `client` (a pool works too, since
@@ -1243,6 +1255,10 @@ async function awardArtistPageCredit(client, { taskId, config = rewardConfig() }
1243
1255
  [t.artist_id, taskId, priced.effective, reason,
1244
1256
  `Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
1245
1257
  );
1258
+ // task 1004438: an approved page is the artist's moment (First Page Approved,
1259
+ // Ten Pages Approved, A Whole Surface). Reconciled here as well as at ship, so
1260
+ // a re-sweep that pays the round outside the ship still reaches the shelf.
1261
+ if (rowCount > 0) await craftAchievements().reconcileAfterCraftCredit(client, t.artist_id, { taskId });
1246
1262
  return rowCount > 0
1247
1263
  ? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
1248
1264
  : { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
@@ -55,6 +55,9 @@ module.exports = {
55
55
  // fires on EVERY ship path (incl. confirmTask / grader_bypass, which never
56
56
  // evaluated achievements before, so most builders only ever got first-claim).
57
57
  reconcileBuilderAchievements: (...a) => achievements.reconcileBuilderAchievements(...a),
58
+ // task 1004438: the craft badges a migration cannot backfill ("A Whole Surface"
59
+ // needs the page inventory, a file) — the lifecycle's sweep runs this once per start.
60
+ backfillCraftAchievements: (...a) => achievements.backfillCraftAchievements(...a),
58
61
  // pure helpers + constants (display / tests)
59
62
  applyKindMultiplier: (...a) => credits.applyKindMultiplier(...a),
60
63
  computeSessionTokenReward: (...a) => credits.computeSessionTokenReward(...a),
@@ -1174,7 +1174,7 @@
1174
1174
  getJSON(`${API}/achievements`),
1175
1175
  getJSON(`${API}/me/recent-ships?limit=all`),
1176
1176
  getJSON(`${API}/versions`),
1177
- getJSON(`${API}/leaderboard`),
1177
+ getJSON(`${API}/public/leaderboard`), // task 1004437: the list with each builder's roles
1178
1178
  getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
1179
1179
  ]);
1180
1180
 
@@ -578,6 +578,7 @@
578
578
  <span>Member since ${fmtDate(builder.created_at)}</span>
579
579
  <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
580
580
  ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
581
+ ${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
581
582
  </div>
582
583
  <div class="profile__disciplines">
583
584
  <span class="profile__disc-label">Preferred work:</span>
@@ -611,7 +612,7 @@
611
612
  setText('stat-karma-sub', karmaTrendText);
612
613
  const statRank = document.getElementById('stat-rank');
613
614
  if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
614
- setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
615
+ setText('stat-rank-sub', rollPlace ? [`#${rollPlace.place} on the leaderboard`, rolePlaceText(rollPlace)].filter(Boolean).join(' · ') : '');
615
616
  const statsSection = document.getElementById('home-stats');
616
617
  if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
617
618
 
@@ -1274,7 +1275,28 @@
1274
1275
  (a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
1275
1276
  );
1276
1277
  const idx = ranked.findIndex((r) => r.github_login === login);
1277
- return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1278
+ if (idx === -1) return null;
1279
+ // task 1004437: the place inside the builder's MAIN role too, the roster's
1280
+ // role toggle read the same way (credits earned in that craft, among the
1281
+ // builders it is shown for), so an artist's card can say where they stand
1282
+ // as an artist and not only beside every engineer's session pay.
1283
+ const me = ranked[idx];
1284
+ const main = me.roles && me.roles.main;
1285
+ let role = null;
1286
+ if (main && me.roles.credits_by_craft) {
1287
+ const credit = (r) => Number(r.roles && r.roles.credits_by_craft && r.roles.credits_by_craft[main]) || 0;
1288
+ const peers = ranked.filter((r) => r.roles && Array.isArray(r.roles.shown) && r.roles.shown.includes(main));
1289
+ const order = peers.map((r, i) => ({ r, i })).sort((a, b) => (credit(b.r) - credit(a.r)) || (a.i - b.i));
1290
+ const at = order.findIndex((x) => x.r === me);
1291
+ if (at !== -1) role = { craft: main, place: at + 1, total: peers.length };
1292
+ }
1293
+ return { place: idx + 1, total: ranked.length, role };
1294
+ }
1295
+ const ROLE_AS = { engineer: 'an engineer', artist: 'an artist', ideator: 'an ideator' };
1296
+ // "#2 as an artist" — only when it says something the overall place does not.
1297
+ function rolePlaceText(p) {
1298
+ if (!p || !p.role || !ROLE_AS[p.role.craft] || p.role.place === p.place) return '';
1299
+ return `#${p.role.place} as ${ROLE_AS[p.role.craft]}`;
1278
1300
  }
1279
1301
 
1280
1302
  // task 1003459 — the Claim button on each Home "Next up" row, through the
@@ -22,6 +22,27 @@
22
22
  .dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
23
23
  .dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
24
24
  .dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
25
+ /* ---- the roles (task 1004437, the approved mock's leaderboard) -------------
26
+ The toggle: pill chips on the glass, the pressed one filled in ink (the mock's
27
+ .chip). The roles column: one pill per role shown, the main one a step
28
+ stronger (the mock's .role / .role--lead, the profile header's chips). */
29
+ .roster-roles { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--sp-sm); }
30
+ .roster-role {
31
+ min-height: 34px; padding: 0 14px; border: 0; border-radius: var(--radius-pill);
32
+ background: var(--glass); color: var(--ink-soft);
33
+ font: inherit; font-size: 13px; cursor: pointer;
34
+ }
35
+ .roster-role:hover { color: var(--ink); background: var(--glass-strong); }
36
+ .roster-role[aria-pressed="true"] { background: var(--ink); color: var(--bg-card); }
37
+ .lb-roles { display: inline-flex; flex-wrap: wrap; gap: 4px; }
38
+ .lb-role {
39
+ display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px;
40
+ border-radius: var(--radius-pill); background: var(--glass);
41
+ font-size: 12.5px; color: var(--ink-soft); white-space: nowrap;
42
+ }
43
+ .lb-role--lead { background: var(--glass-strong); color: var(--ink); }
44
+ .lb-roles--none, .lb-none { color: var(--ink-faint); }
45
+
25
46
  /* The roster's place cell carries two facts: the place, and under it the
26
47
  builder's number in the same mono/faint voice the login uses (task
27
48
  1002837). Block display so the id never wraps up beside the place. */
@@ -34,6 +34,9 @@
34
34
  link into index.html. It is a page now; /#leaderboard redirects here. -->
35
35
  <section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
36
36
  <h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
37
+ <!-- The role toggle (task 1004437, owner decision (b)): Everyone, then one
38
+ chip per craft; a craft ranks by the credits earned in it. -->
39
+ <div class="roster-roles" id="roster-roles" role="group" aria-label="Show"></div>
37
40
  <div id="roster-filters"></div>
38
41
  <div id="roster-body">
39
42
  <div class="profile__placeholder">Loading the roster…</div>
@@ -49,17 +49,31 @@
49
49
  'first-parallel-claim': '⚓',
50
50
  'streak-3': '\u{1F525}',
51
51
  'streak-7': '\u{1F30B}',
52
- 'art-pipeline-shipper': '\u{1F3A8}',
52
+ // task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
53
+ 'art-pipeline-shipper': '\u{1F6E0}\u{FE0F}',
54
+ 'first-thought-ignited': '\u{1F4A1}',
55
+ 'first-full-idea': '\u{1F4DC}',
56
+ 'first-board-ratification': '\u{1F3DB}\u{FE0F}',
57
+ 'thought-built-ten': '\u{1F31F}',
58
+ 'first-page-approved': '\u{1F3A8}',
59
+ 'ten-pages-approved': '\u{1F5BC}\u{FE0F}',
60
+ 'whole-surface-tweaked': '\u{1F5FA}\u{FE0F}',
53
61
  'gds-shipper': '⚙️',
54
62
  'breaking-the-100c-bar': '\u{1F4AF}',
55
63
  };
64
+ // An achievement id as a sentence: "first-page-approved" → "First page approved".
65
+ function achWords(id) {
66
+ var s = String(id || '').replace(/-/g, ' ');
67
+ return s.charAt(0).toUpperCase() + s.slice(1);
68
+ }
56
69
  function achRowHtml(ids) {
57
70
  if (!Array.isArray(ids) || !ids.length) return '';
58
71
  var inner = ids.map(function (id) {
59
72
  var emoji = ACH_EMOJI[id];
60
- // Title is the achievement id (kebab) — terse enough that hover discloses
61
- // the laurel without overstating the prose.
62
- return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
73
+ // The title is the achievement's name as words ("First page approved"), not
74
+ // its id slug (task 1004438): it is what a hover shows and a screen reader says.
75
+ var words = achWords(id);
76
+ return emoji ? '<span class="lb-ach" title="' + esc(words) + '" aria-label="' + esc(words) + '">' + emoji + '</span>' : '';
63
77
  }).join('');
64
78
  return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
65
79
  }
@@ -94,9 +108,34 @@
94
108
  return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
95
109
  }
96
110
 
111
+ // task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
112
+ // list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
113
+ // main role first, then every craft the builder has EARNED in — evidence of
114
+ // work, not only what they declared — and `credits_by_craft` is the ledger
115
+ // split. Owner decision (b): a column naming them AND the role toggle above,
116
+ // which ranks by the credits earned in that craft. The figure a role ranks by
117
+ // is in the row (ADR 0255: a public list is ordered only by what it publishes).
118
+ var CRAFTS = ['engineer', 'artist', 'ideator'];
119
+ var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
120
+ var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
121
+ function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
122
+ function craftCredits(b, craft) {
123
+ var by = b.roles && b.roles.credits_by_craft;
124
+ return by ? (Number(by[craft]) || 0) : 0;
125
+ }
126
+ function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
127
+ function rolesHtml(b) {
128
+ var shown = shownOf(b);
129
+ if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
130
+ return '<span class="lb-roles">' + shown.map(function (c, i) {
131
+ return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
132
+ }).join('') + '</span>';
133
+ }
134
+
97
135
  var all = [];
98
136
  var query = '';
99
137
  var sortBy = '';
138
+ var role = '';
100
139
 
101
140
  // PLACE IS CANONICAL, not the row's position in the current sort.
102
141
  // hall-render's leaderboardPlace() already defines a builder's place as
@@ -108,6 +147,16 @@
108
147
  rows.slice()
109
148
  .sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
110
149
  .forEach(function (b, i) { b.__place = i + 1; });
150
+ // A role's place is the same idea inside that role: credits earned in the
151
+ // craft, descending, among the builders it is shown for. Stamped once too,
152
+ // so the place under a role toggle does not renumber under a search.
153
+ rows.forEach(function (b) { b.__placeBy = {}; });
154
+ CRAFTS.forEach(function (c) {
155
+ rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
156
+ .map(function (b, i) { return { b: b, i: i }; })
157
+ .sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
158
+ .forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
159
+ });
111
160
  // The efficiency title is stamped here too, so the division and the string
112
161
  // work happen ONCE per load rather than per visible row per render (every
113
162
  // sort, search keystroke and page turn re-renders).
@@ -116,6 +165,7 @@
116
165
  }
117
166
 
118
167
  function matches(b) {
168
+ if (role && shownOf(b).indexOf(role) === -1) return false;
119
169
  if (!query) return true;
120
170
  var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
121
171
  return hay.indexOf(query) !== -1;
@@ -130,7 +180,8 @@
130
180
  tokens: function (b) { return -(Number(b.tokens_used) || 0); },
131
181
  };
132
182
  function ordered(rows) {
133
- var key = SORTS[sortBy];
183
+ // The default lens is credits; under a role it is the credits that role earned.
184
+ var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
134
185
  if (!key) return rows;
135
186
  return rows.map(function (b, i) { return { b: b, i: i }; })
136
187
  .sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
@@ -140,7 +191,14 @@
140
191
  var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
141
192
  var ledger = null;
142
193
 
194
+ // Tokens only mean something for AI sessions. A builder who works in the
195
+ // browser (an artist in the studio, an ideator in the room) records none, and
196
+ // a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
197
+ // words instead, and the tokens-per-credit figure is offered only where there
198
+ // are tokens to divide.
199
+ function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
143
200
  function efficiencyTitle(b) {
201
+ if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
144
202
  var credits = Number(b.total_credits) || 0;
145
203
  var one = currencyLabel().toLowerCase().replace(/s$/, '');
146
204
  if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
@@ -168,20 +226,69 @@
168
226
  }
169
227
 
170
228
  function rowHtml(b) {
229
+ var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
230
+ var tokens = hasTokens(b)
231
+ ? esc(fmtInt(b.tokens_used))
232
+ : '<span class="lb-none" aria-label="No AI sessions">—</span>';
171
233
  return '<tr>'
172
- + '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
234
+ + '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
173
235
  + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
236
+ + '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
174
237
  + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
175
- + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
238
+ + '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
176
239
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
177
- + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + esc(fmtInt(b.tokens_used)) + '</td>'
240
+ + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
178
241
  + '</tr>';
179
242
  }
180
243
 
244
+ function columns() {
245
+ return ['Place', 'Builder', 'Roles', 'Rank',
246
+ { label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
247
+ { label: 'Karma', class: 'ledger__num' },
248
+ { label: 'Tokens', class: 'ledger__num' }];
249
+ }
250
+
251
+ function makeRosterLedger() {
252
+ body.textContent = '';
253
+ body.className = '';
254
+ ledger = kit.makeLedger({
255
+ mount: body,
256
+ columns: columns(),
257
+ rowHtml: rowHtml,
258
+ emptyMsg: role
259
+ ? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
260
+ : 'No builder on the roster matches that search.',
261
+ pager: pager,
262
+ });
263
+ }
264
+
181
265
  function rerender() {
182
266
  if (ledger) ledger.render(ordered(all.filter(matches)));
183
267
  }
184
268
 
269
+ // The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
270
+ // chip ranks by the credits that craft earned and shows only the builders it
271
+ // is shown for. The ledger is rebuilt so the credits header names the craft.
272
+ function roleChipsHtml() {
273
+ return ROLE_CHIPS.map(function (c) {
274
+ return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
275
+ }).join('');
276
+ }
277
+ function mountRoleChips() {
278
+ var mount = document.getElementById('roster-roles');
279
+ if (!mount) return;
280
+ mount.innerHTML = roleChipsHtml();
281
+ mount.addEventListener('click', function (e) {
282
+ var btn = e.target.closest('[data-role]');
283
+ if (!btn || btn.dataset.role === role) return;
284
+ role = btn.dataset.role;
285
+ mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
286
+ pager.page = 1;
287
+ makeRosterLedger();
288
+ rerender();
289
+ });
290
+ }
291
+
185
292
  function notice(msg) {
186
293
  body.innerHTML = '';
187
294
  body.className = 'note';
@@ -221,23 +328,16 @@
221
328
  rerender();
222
329
  },
223
330
  });
224
- body.textContent = '';
225
- body.className = '';
226
- ledger = kit.makeLedger({
227
- mount: body,
228
- columns: ['Place', 'Builder', 'Rank',
229
- { label: currencyLabel(), class: 'ledger__num' },
230
- { label: 'Karma', class: 'ledger__num' },
231
- { label: 'Tokens', class: 'ledger__num' }],
232
- rowHtml: rowHtml,
233
- emptyMsg: 'No builder on the roster matches that search.',
234
- pager: pager,
235
- });
331
+ mountRoleChips();
332
+ makeRosterLedger();
236
333
  rerender();
237
334
  }
238
335
 
239
336
  var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
240
- fetch('/api/bongos/leaderboard', { headers: { Accept: 'application/json' } })
337
+ // The PUBLIC list (task 1004437): the same rows the authed /leaderboard
338
+ // carries plus each builder's roles, and the one the status page reads, so
339
+ // the hall and the public page cannot rank a role differently.
340
+ fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
241
341
  .then(function (r) {
242
342
  if (!r.ok) throw new Error('http ' + r.status);
243
343
  return r.json();
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/leaderboard.json carries thirteen builders with avatars, ranks, karma and tokens).",
2
+ "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/public__leaderboard.json, since task 1004437 the list the page reads: fifteen builders with avatars, ranks, karma, tokens and each one's roles, including an artist and an ideator with no AI sessions).",
3
3
  "page": "/builders/roster",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -44,6 +44,31 @@
44
44
  ]
45
45
  }
46
46
  },
47
+ "artists": {
48
+ "_": "The role toggle with Artists pressed (task 1004437, owner decision (b)): only the builders shown as artists, ranked by the credits that craft earned, the credits header naming the craft. Against the harness the browser-only artist (2001) leads, with a dash where an AI builder's tokens would be. The harness serves public__leaderboard.json, which carries the roles.",
49
+ "auth": true,
50
+ "actions": [
51
+ [
52
+ "wait",
53
+ 700
54
+ ],
55
+ [
56
+ "click",
57
+ "[data-role=\"artist\"]"
58
+ ],
59
+ [
60
+ "wait",
61
+ 200
62
+ ]
63
+ ],
64
+ "expect": {
65
+ "visible": [
66
+ "#roster-scroll",
67
+ "#roster-roles",
68
+ ".lb-roles"
69
+ ]
70
+ }
71
+ },
47
72
  "search": {
48
73
  "auth": true,
49
74
  "actions": [
@@ -8,3 +8,23 @@
8
8
  - **It is deliberately UNGATED,** and `tests/hall_page_gate_map.mjs` spells that out rather than defaulting. Its one data source, `GET /leaderboard`, is deliberately unauthenticated and mirrors the public status surface; a page must not be gated tighter than the API it fronts, and it was already reachable to a stranger as a view on the ungated index. No visibility predicate is applied and that is correct — privacy suppresses cross-project surfaces only, so a `private` builder still appears on the roster of a project they build on (ADR 0225).
9
9
 
10
10
  Pinned by `tests/hall_nav.mjs` (the Community group's middle id is `roster`) and `tests/hall_page_gate_map.mjs`. Measured through `roster.states.json` — whose `out` state renders the REAL page, since the page is ungated.
11
+
12
+ ## The roles (task 1004437, WA6.RP07)
13
+
14
+ Owner decision (b), 2026-09-30: the leaderboard gets BOTH a column naming each builder's roles and the Everyone / Engineers / Artists / Ideators toggle above it. The approved mock is `docs/design/mocks/profile-roles/` (`?state=board`, `?state=board-artists`).
15
+
16
+ - **The roster now reads `GET /public/leaderboard`,** the list the status page reads, which carries each row's `roles` (`src/bongos/role-stats.js` `readRolesForBuilders`: the profile's own main/shown rules, two reads for the whole page) and, since this task, `karma`. The hall and the public page cannot rank a role differently. The authed `/leaderboard` is unchanged and no longer read by the hall.
17
+ - **Roles column:** one pill per role in `roles.shown`, the main one a step stronger. `shown` is the main role plus every craft the builder has EARNED in, so it is evidence of work, not only a declared preference.
18
+ - **The toggle** keeps the builders a craft is shown for and ranks them by the credits that craft earned (`roles.credits_by_craft`, in the row: ADR 0255). The place column becomes the place inside that craft, the credits header says "Credits as artist", and the karma/rank/tokens lenses still apply on top.
19
+ - **Tokens only where they apply.** A builder with no recorded AI session (an artist in the studio, an ideator in the room) reads a dash, and the cell's tooltip says no AI sessions were recorded, instead of a 0 and a "0 tokens per credit" figure that read as "did nothing".
20
+ - **Home's Standing card** reads the same list and adds "#N as an artist" beside "#N on the leaderboard" when the place inside the builder's main role differs from the overall one (`hall-render.js` `rolePlaceText`).
21
+ - **The status page** names each builder's roles under their name, on that page's own palette (`status.js` `renderRoles`).
22
+
23
+ Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js over the harness fixture with the toggle clicked, the Standing card's place and the status renderer, run). The `artists` state in `roster.states.json` renders the toggle pressed.
24
+
25
+ ### Deliberate differences from the mock (RP07)
26
+
27
+ - **No "Best known for" column.** Each row's headline count (works shipped, pages approved, thoughts ignited) is a per-builder read in three different modules; the list would pay three more reads for every page of builders. The profile, one click away, leads with exactly that number.
28
+ - **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
29
+ - **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
30
+ - **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
@@ -944,6 +944,29 @@ async function runPromotionSweep(deps = {}) {
944
944
  }
945
945
  }
946
946
 
947
+ // The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
948
+ // migration cannot backfill, "A Whole Surface", needs the page inventory, which is
949
+ // a file the database never sees. Run ONCE per process start, on the first tick,
950
+ // through the reward port (economy is optional; no port, no sweep). Pure DB and
951
+ // idempotent, so a restart that runs it again unlocks nothing twice; a failure is
952
+ // logged and never touches the other sweeps.
953
+ let craftBackfillDone = false;
954
+ async function runCraftAchievementBackfill(deps = {}) {
955
+ const { logFn, errFn } = loggers(deps);
956
+ if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
957
+ craftBackfillDone = true;
958
+ const reward = deps.reward || seams.resolveOptional('reward');
959
+ if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
960
+ try {
961
+ const out = await reward.backfillCraftAchievements({});
962
+ if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
963
+ return out;
964
+ } catch (e) {
965
+ errFn(`[publish-reconciler] craft achievement backfill (non-blocking): ${e && e.message}`);
966
+ return { skipped: 'error', error: e && e.message };
967
+ }
968
+ }
969
+
947
970
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
948
971
  // by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
949
972
  // default whenever a push credential is configured; the route's getPublishStatus
@@ -964,6 +987,7 @@ function start(deps = {}) {
964
987
  runCostSweep(deps).catch(crashHandler('cost sweep'));
965
988
  runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
966
989
  runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
990
+ runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
967
991
  };
968
992
 
969
993
  firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
@@ -1002,6 +1026,7 @@ module.exports = {
1002
1026
  runCostSweep,
1003
1027
  runPromotionSweep,
1004
1028
  runCriterionSweep,
1029
+ runCraftAchievementBackfill,
1005
1030
  couldBeStaleGenerated,
1006
1031
  describeTarget,
1007
1032
  INTERVAL_MS,
@@ -1076,17 +1076,26 @@ function updateHeadlineShippedFromItems() {
1076
1076
 
1077
1077
  // ---------- leaderboard ----------
1078
1078
 
1079
- // Set is locked at 8 for V2 (per limitations/pms-v2.md), so hardcoding the
1080
- // emoji map here is cheaper than another API round-trip per page load.
1081
- // Mirrors public-builders/builders.js — keep both in sync if the catalog
1082
- // ever shifts (would require an ADR + version bump per the V2 contract).
1079
+ // The catalog is small and changes only by migration (the latest: the seven
1080
+ // craft achievements of task 1004438, migrations/core_270), so hardcoding the
1081
+ // emoji map here is cheaper than another API round-trip per page load. Mirrors
1082
+ // modules/hall-ui/public/roster.js ACH_EMOJI — tests/craft_achievements.mjs
1083
+ // holds both maps to the migration's ids and icons.
1083
1084
  const ACH_EMOJI = {
1084
1085
  'first-ship': '🚢',
1085
1086
  'first-claim': '🪙',
1086
1087
  'first-parallel-claim': '⚓',
1087
1088
  'streak-3': '🔥',
1088
1089
  'streak-7': '🌋',
1089
- 'art-pipeline-shipper': '🎨',
1090
+ // task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
1091
+ 'art-pipeline-shipper': '🛠️',
1092
+ 'first-thought-ignited': '💡',
1093
+ 'first-full-idea': '📜',
1094
+ 'first-board-ratification': '🏛️',
1095
+ 'thought-built-ten': '🌟',
1096
+ 'first-page-approved': '🎨',
1097
+ 'ten-pages-approved': '🖼️',
1098
+ 'whole-surface-tweaked': '🗺️',
1090
1099
  'gds-shipper': '⚙️',
1091
1100
  'breaking-the-100c-bar': '💯',
1092
1101
  };
@@ -1101,14 +1110,35 @@ function renderAchievementRow(ids) {
1101
1110
  if (!emoji) continue;
1102
1111
  const span = document.createElement('span');
1103
1112
  span.className = 'leader__ach';
1104
- span.title = id;
1105
- span.setAttribute('aria-label', id);
1113
+ // the name as words ("First page approved"), not the id slug (task 1004438)
1114
+ const words = String(id).replace(/-/g, ' ');
1115
+ span.title = words.charAt(0).toUpperCase() + words.slice(1);
1116
+ span.setAttribute('aria-label', span.title);
1106
1117
  span.textContent = emoji;
1107
1118
  row.appendChild(span);
1108
1119
  }
1109
1120
  return row.children.length > 0 ? row : null;
1110
1121
  }
1111
1122
 
1123
+ // task 1004437 (WA6.RP07): each row names its roles, main first — the crafts
1124
+ // the builder has EARNED in, from the row's own `roles.shown`, not only what
1125
+ // they declared. The same words the hall's roster uses. Text only, never markup.
1126
+ const ROLE_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
1127
+ function renderRoles(roles) {
1128
+ const shown = roles && Array.isArray(roles.shown) ? roles.shown.filter((c) => ROLE_WORD[c]) : [];
1129
+ if (!shown.length) return null;
1130
+ const row = document.createElement('span');
1131
+ row.className = 'leader__roles';
1132
+ row.setAttribute('aria-label', 'roles');
1133
+ shown.forEach((c, i) => {
1134
+ const chip = document.createElement('span');
1135
+ chip.className = i === 0 ? 'leader__role leader__role--lead' : 'leader__role';
1136
+ chip.textContent = ROLE_WORD[c];
1137
+ row.appendChild(chip);
1138
+ });
1139
+ return row;
1140
+ }
1141
+
1112
1142
  async function loadLeaderboard() {
1113
1143
  const data = await fetchJson('/api/bongos/public/leaderboard');
1114
1144
  const rows = data.leaderboard || [];
@@ -1138,6 +1168,8 @@ async function loadLeaderboard() {
1138
1168
  login.className = 'leader__login';
1139
1169
  login.textContent = `@${b.github_login}`;
1140
1170
  name.appendChild(login);
1171
+ const roles = renderRoles(b.roles);
1172
+ if (roles) name.appendChild(roles);
1141
1173
  const badges = renderAchievementRow(b.achievements);
1142
1174
  if (badges) name.appendChild(badges);
1143
1175
  const credits = document.createElement('div');
@@ -749,6 +749,30 @@ main {
749
749
  letter-spacing: 0;
750
750
  }
751
751
 
752
+ /* The roles under each builder's name (task 1004437): one small tag per role
753
+ the builder has earned in, the main one a step stronger, as the hall's
754
+ roster and the approved mock draw them, on this page's own palette. */
755
+ .leader__roles {
756
+ display: flex;
757
+ flex-wrap: wrap;
758
+ gap: 4px;
759
+ margin-top: 4px;
760
+ }
761
+ .leader__role {
762
+ display: inline-flex;
763
+ align-items: center;
764
+ min-height: 22px;
765
+ padding: 0 8px;
766
+ border: 1px solid var(--rule-soft);
767
+ font-family: var(--font-body);
768
+ font-size: 0.78rem;
769
+ font-weight: 400;
770
+ letter-spacing: 0;
771
+ color: var(--ink-soft);
772
+ text-transform: none;
773
+ }
774
+ .leader__role--lead { color: var(--ink); border-color: var(--rule); }
775
+
752
776
  /* Inline laurels next to each architect's name. Slightly dimmed so the
753
777
  name still leads the row; emoji glyphs would otherwise punch above the
754
778
  marble palette. PMS-V2 #54 */
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.1",
9
+ "version": "1.21.2",
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.21.1",
3
+ "version": "1.21.2",
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",
@@ -8662,6 +8662,10 @@
8662
8662
  ],
8663
8663
  "1.21.0": [],
8664
8664
  "1.21.1": [
8665
+ {
8666
+ "id": "1004437",
8667
+ "text": "The builders list now shows each person's roles (Engineer, Artist, Ideator, main one first), counting a role once the person has earned credits in it. Buttons above it (Everyone, Engineers, Artists, Ideators) rank people by th"
8668
+ },
8665
8669
  {
8666
8670
  "id": "1004522",
8667
8671
  "text": "The Governor City design now lives in the project itself, not only in a private design canvas. This is the design where a governor's home is a small animated city and each part of the government is a building. All six design f"
@@ -8694,5 +8698,11 @@
8694
8698
  "id": "1004505",
8695
8699
  "text": "A new project's address now works the first time you open it. The platform creates the address before anything else, and the hub only links it once the platform has seen it answer. Before, an early click could leave your home"
8696
8700
  }
8701
+ ],
8702
+ "1.21.2": [
8703
+ {
8704
+ "id": "1004438",
8705
+ "text": "Artists and ideators now earn achievements for their own work, not only for shipping code. Ideators: First Thought Ignited, First Full Idea, Ratified by the Board, and A Thought That Built Ten. Artists: First Page Approved, Te"
8706
+ }
8697
8707
  ]
8698
8708
  }