@bongos/core 1.24.39 → 1.24.40

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.
@@ -136,6 +136,125 @@
136
136
  var sortBy = '';
137
137
  var role = '';
138
138
 
139
+ // ---- the government columns (task 1005048, owner decision on idea 1001313) --
140
+ // Watch kept a second list of the same people; it is this page now. A viewer
141
+ // the server lets read GET /builders/roster (the builder.roster.read atom) also
142
+ // sees each builder's active claims, the stale-claim flag and standing, and
143
+ // gets the rank and offboard controls that list carried. For anyone else that
144
+ // read is refused and the page draws the public list unchanged: the wall is
145
+ // the route, never this file.
146
+ var gov = null; // builder id → their GET /builders/roster row, once it answers
147
+ var viewerId = null;
148
+ var claims = '';
149
+ var RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
150
+ var client = window.BongosClient
151
+ ? window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false })
152
+ : null;
153
+ var toast = function (m) { if (OTB && OTB.toast) OTB.toast(m); };
154
+ // By value: /me and the roster can disagree on string vs number ids (#348).
155
+ function isViewer(b) { return viewerId != null && b && b.id != null && String(b.id) === String(viewerId); }
156
+ function govOf(b) { return gov && b.id != null ? gov[String(b.id)] || null : null; }
157
+ // Resolves to the government rows, or null for every refusal and failure.
158
+ // The roster read is asked only of a viewer whose /me map says they hold
159
+ // builder.roster.read, so a stranger or a builder without the seat costs this
160
+ // public page no extra round trip. /me itself is the shell's read, shared on
161
+ // window.__hallReads (the gov-feeds.js precedent); asked here only without it.
162
+ function readMe() {
163
+ var shared = window.__hallReads && window.__hallReads['/me'];
164
+ if (shared) return Promise.resolve(shared);
165
+ return client.request('GET', '/api/bongos/me').then(function (res) { return res && res.ok ? res.data : null; });
166
+ }
167
+ function loadGov() {
168
+ if (!client) return Promise.resolve(null);
169
+ return readMe().then(function (me) {
170
+ var perms = me && me.permissions;
171
+ if (!perms || perms['builder.roster.read'] !== true) return null;
172
+ var who = me.builder || me;
173
+ // No id from /me marks no row as yours, rather than the wrong one.
174
+ viewerId = who && who.id != null ? who.id : null;
175
+ return client.request('GET', '/api/bongos/builders/roster').then(function (roster) {
176
+ if (!roster || !roster.ok || !roster.data || !Array.isArray(roster.data.builders)) return null;
177
+ return roster.data.builders;
178
+ });
179
+ }).catch(function () { return null; });
180
+ }
181
+ function applyGov(rows) {
182
+ gov = {};
183
+ rows.forEach(function (r) { if (r.id != null) gov[String(r.id)] = r; });
184
+ // The rank a control just changed reads from the fresh row.
185
+ all.forEach(function (b) { var g = govOf(b); if (g && g.rank) b.rank = g.rank; });
186
+ }
187
+ function claimsHtml(g) {
188
+ var stale = g.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
189
+ return '<td class="ledger__num' + (g.stale_claim ? ' roster-warn' : '') + '">' + esc(fmtInt(g.active_claim_count || 0)) + stale + '</td>';
190
+ }
191
+ function rankControlHtml(b) {
192
+ var opts = RANK_OPTIONS.slice();
193
+ if (b.rank && opts.indexOf(b.rank) === -1) opts.unshift(b.rank);
194
+ return '<select data-roster-rank data-id="' + esc(b.id) + '" data-current="' + esc(b.rank || '') + '" aria-label="Rank">'
195
+ + opts.map(function (rk) {
196
+ return '<option value="' + esc(rk) + '"' + (rk === b.rank ? ' selected' : '') + '>' + esc(rankLabel(rk)) + '</option>';
197
+ }).join('') + '</select>';
198
+ }
199
+ // The viewer's own row offers nothing (task 1003720): both routes refuse a
200
+ // self-change outright, the guard that stops the last top-rank builder locking
201
+ // everyone out, so a live control there could only answer with a refusal.
202
+ function actionHtml(b, g) {
203
+ if (isViewer(b)) return '<span class="roster-self">That’s you</span>';
204
+ var inactive = String(g.status) === 'inactive';
205
+ return '<button type="button" class="btn-ghost' + (inactive ? '' : ' btn-ghost--danger') + '" data-roster-status="'
206
+ + (inactive ? 'active' : 'inactive') + '" data-id="' + esc(b.id) + '">' + (inactive ? 'Reactivate' : 'Offboard') + '</button>';
207
+ }
208
+ function govFoot() {
209
+ var foot = document.getElementById('roster-gov-foot');
210
+ if (!foot) return;
211
+ if (!gov) { foot.hidden = true; return; }
212
+ foot.textContent = 'Because you hold a government seat you also see active claims and standing, and can change a builder’s rank. '
213
+ + (all.some(isViewer)
214
+ ? 'Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last '
215
+ + rankLabel('archon') + ' locking everyone out — so another ' + rankLabel('archon') + ' does it.'
216
+ : '');
217
+ foot.hidden = false;
218
+ }
219
+ function patchBuilder(id, what, payload) {
220
+ return client.request('PATCH', '/api/bongos/builders/' + encodeURIComponent(id) + '/' + what, { body: payload })
221
+ .then(function (res) {
222
+ if (res && res.ok) return res.data;
223
+ var d = (res && res.data) || {};
224
+ var env = d.error && typeof d.error === 'object' ? d.error : null;
225
+ throw new Error((env && env.message) || d.message || (typeof d.error === 'string' ? d.error : '') || ('http ' + (res && res.status)));
226
+ });
227
+ }
228
+ function refreshGov() {
229
+ return loadGov().then(function (rows) { if (rows) applyGov(rows); else gov = null; makeRosterLedger(); rerender(); govFoot(); });
230
+ }
231
+ function wireGov() {
232
+ if (body['wired:gov']) return;
233
+ body['wired:gov'] = true;
234
+ // Delegated on the mount, so a page flip cannot strand a row without one.
235
+ body.addEventListener('change', function (e) {
236
+ var sel = e.target && e.target.closest ? e.target.closest('[data-roster-rank]') : null;
237
+ if (!sel) return;
238
+ var prev = sel.getAttribute('data-current');
239
+ var rank = sel.value;
240
+ if (rank === prev) return;
241
+ sel.disabled = true;
242
+ patchBuilder(sel.getAttribute('data-id'), 'rank', { rank: rank })
243
+ .then(function () { toast('Rank set to ' + rankLabel(rank) + '.'); return refreshGov(); })
244
+ .catch(function (err) { sel.value = prev; sel.disabled = false; toast('Could not change rank: ' + (err.message || err)); });
245
+ });
246
+ body.addEventListener('click', function (e) {
247
+ var btn = e.target && e.target.closest ? e.target.closest('[data-roster-status]') : null;
248
+ if (!btn) return;
249
+ var status = btn.getAttribute('data-roster-status');
250
+ var reason = status === 'inactive' ? (window.prompt('Reason for offboarding (optional):', '') || null) : null;
251
+ btn.disabled = true;
252
+ patchBuilder(btn.getAttribute('data-id'), 'status', { status: status, reason: reason })
253
+ .then(function () { toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.'); return refreshGov(); })
254
+ .catch(function (err) { btn.disabled = false; toast('Could not change standing: ' + (err.message || err)); });
255
+ });
256
+ }
257
+
139
258
  // PLACE IS CANONICAL, not the row's position in the current sort.
140
259
  // hall-render's leaderboardPlace() already defines a builder's place as
141
260
  // credits-descending "independent of whatever column the table is currently
@@ -165,6 +284,12 @@
165
284
 
166
285
  function matches(b) {
167
286
  if (role && shownOf(b).indexOf(role) === -1) return false;
287
+ if (claims) {
288
+ var g = govOf(b);
289
+ if (!g) return false;
290
+ if (claims === 'stale' && !g.stale_claim) return false;
291
+ if (claims === 'holding' && !(Number(g.active_claim_count) > 0)) return false;
292
+ }
168
293
  if (!query) return true;
169
294
  var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
170
295
  return hay.indexOf(query) !== -1;
@@ -229,22 +354,31 @@
229
354
  var tokens = hasTokens(b)
230
355
  ? esc(fmtInt(b.tokens_used))
231
356
  : '<span class="lb-none" aria-label="No AI sessions">—</span>';
357
+ var g = govOf(b);
358
+ var govCells = g
359
+ ? claimsHtml(g)
360
+ + '<td><span class="badge badge--status">' + esc(g.status || '—') + '</span></td>'
361
+ + '<td>' + actionHtml(b, g) + '</td>'
362
+ : (gov ? '<td class="ledger__num">—</td><td>—</td><td></td>' : '');
363
+ var rankCell = g && !isViewer(b) ? rankControlHtml(b) : rankBadgeHtml(b.rank);
232
364
  return '<tr>'
233
365
  + '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
234
366
  + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
235
367
  + '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
236
- + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
368
+ + '<td class="ledger__rank">' + rankCell + '</td>'
237
369
  + '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
238
370
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
239
371
  + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
372
+ + govCells
240
373
  + '</tr>';
241
374
  }
242
375
 
243
376
  function columns() {
244
- return ['Place', 'Builder', 'Roles', 'Rank',
377
+ var cols = ['Place', 'Builder', 'Roles', 'Rank',
245
378
  { label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
246
379
  { label: 'Karma', class: 'ledger__num' },
247
380
  { label: 'Tokens', class: 'ledger__num' }];
381
+ return gov ? cols.concat([{ label: 'Active claims', class: 'ledger__num' }, 'Standing', 'Action']) : cols;
248
382
  }
249
383
 
250
384
  function makeRosterLedger() {
@@ -294,8 +428,21 @@
294
428
  body.textContent = msg;
295
429
  }
296
430
 
297
- function mountRoster(rows) {
298
- all = stampPlaces(Array.isArray(rows) ? rows : []);
431
+ function mountRoster(rows, govRows) {
432
+ all = Array.isArray(rows) ? rows : [];
433
+ if (govRows) {
434
+ // The government list is every builder; the public one only those who have
435
+ // earned. Nobody drops off the one list for having earned nothing yet.
436
+ var seen = {};
437
+ all.forEach(function (b) { if (b.id != null) seen[String(b.id)] = true; });
438
+ govRows.forEach(function (r) {
439
+ if (r.id == null || seen[String(r.id)]) return;
440
+ all.push({ id: r.id, github_login: r.github_login, display_name: r.display_name, avatar_url: r.avatar_url,
441
+ rank: r.rank, total_credits: r.total_credits, karma: r.karma, tokens_used: 0 });
442
+ });
443
+ }
444
+ all = stampPlaces(all);
445
+ if (govRows) applyGov(govRows);
299
446
  if (!all.length) {
300
447
  notice('Nobody has earned anything on this project yet. A builder joins the roster the '
301
448
  + 'first time they ship a claimed task.');
@@ -307,10 +454,19 @@
307
454
  + ' on this project, ranked by ' + currencyLabel().toLowerCase() + ' earned. This '
308
455
  + 'project’s own ledger — everyone who builds here is listed.';
309
456
  }
457
+ // Watch's stale-claims tile links here as ?claims=stale.
458
+ var asked = '';
459
+ try { asked = new URLSearchParams(window.location.search).get('claims') || ''; } catch (e) { asked = ''; }
460
+ claims = gov && (asked === 'stale' || asked === 'holding') ? asked : '';
461
+ var claimsControl = gov ? [{ id: 'roster-claims', label: 'Claims', value: claims, options: [
462
+ { value: '', label: 'anyone' },
463
+ { value: 'holding', label: 'holding a claim' },
464
+ { value: 'stale', label: 'stale claims' },
465
+ ] }] : [];
310
466
  kit.makeFilterBar({
311
467
  mount: document.getElementById('roster-filters'),
312
468
  search: { id: 'roster-q', label: 'Search', placeholder: 'name or login' },
313
- primary: [
469
+ primary: claimsControl.concat([
314
470
  { id: 'roster-sort',
315
471
  label: 'Sort',
316
472
  options: [
@@ -319,10 +475,11 @@
319
475
  { value: 'karma', label: 'most karma' },
320
476
  { value: 'tokens', label: 'most tokens' },
321
477
  ] },
322
- ],
478
+ ]),
323
479
  onChange: function (vals) {
324
480
  query = String(vals['roster-q'] || '').trim().toLowerCase();
325
481
  sortBy = String(vals['roster-sort'] || '');
482
+ if (gov) claims = String(vals['roster-claims'] || '');
326
483
  pager.page = 1;
327
484
  rerender();
328
485
  },
@@ -330,21 +487,23 @@
330
487
  mountRoleChips();
331
488
  makeRosterLedger();
332
489
  rerender();
490
+ if (gov) { wireGov(); govFoot(); }
333
491
  }
334
492
 
335
493
  var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
336
494
  // The PUBLIC list (task 1004437): the same rows the authed /leaderboard
337
495
  // carries plus each builder's roles, and the one the status page reads, so
338
496
  // the hall and the public page cannot rank a role differently.
497
+ var govLoad = loadGov();
339
498
  fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
340
499
  .then(function (r) {
341
500
  if (!r.ok) throw new Error('http ' + r.status);
342
501
  return r.json();
343
502
  })
344
503
  .then(function (data) {
345
- return foundingLoad.then(function (list) {
346
- marks = window.OTBFounding ? window.OTBFounding.byId(list) : {};
347
- mountRoster(data && data.leaderboard);
504
+ return Promise.all([foundingLoad, govLoad]).then(function (got) {
505
+ marks = window.OTBFounding ? window.OTBFounding.byId(got[0]) : {};
506
+ mountRoster(data && data.leaderboard, got[1]);
348
507
  });
349
508
  })
350
509
  .catch(function () { notice('Couldn’t load the roster right now.'); });
@@ -21,12 +21,6 @@
21
21
  /* Row actions inside a ledger cell: ghost pills in a row. */
22
22
  .watch-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
23
23
 
24
- /* The reader's own roster row (task 1003720). Nobody may change their own rank
25
- or standing — the server refuses both outright — so that row states a fact
26
- where the others carry a control, and a fact is written in the faint ink, not
27
- painted as a disabled pill. The foot line under the table says why. */
28
- .watch-self { color: var(--ink-faint); }
29
-
30
24
  /* A sealed or failed section says so in place. Three states, three sentences:
31
25
  "nothing here" is the kit's empty state, this is "you may not see this" and
32
26
  "could not load". */
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Watch — {{worldName}}</title>
7
- <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, roster, reviews, and repo health.">
7
+ <meta name="description" content="Archon oversight for {{worldName}} — access requests, security, stale claims, reviews, and repo health.">
8
8
  <!-- The hub's origin, for the link from a guild's head in the applications queue to
9
9
  its page on the hub (task 1002304). Empty on a vanilla instance: no link. -->
10
10
  <meta name="hub-origin" content="{{hubOrigin}}">
@@ -48,7 +48,7 @@
48
48
  <div class="stat-row ov-summary" id="watch-attention" hidden>
49
49
  <a class="stat-tile" href="#people"><span class="stat-tile__label">Access requests</span><span class="stat-tile__num" id="att-access">—</span><span class="stat-tile__sub">pending, on the People tab</span></a>
50
50
  <a class="stat-tile" href="#security"><span class="stat-tile__label">Security reports</span><span class="stat-tile__num" id="att-security">—</span><span class="stat-tile__sub">open, on the Security tab</span></a>
51
- <a class="stat-tile" href="#people"><span class="stat-tile__label">Stale claims</span><span class="stat-tile__num" id="att-stale">—</span><span class="stat-tile__sub">builders, on the People tab</span></a>
51
+ <a class="stat-tile" href="/roster?claims=stale"><span class="stat-tile__label">Stale claims</span><span class="stat-tile__num" id="att-stale">—</span><span class="stat-tile__sub">builders, on the Builders page</span></a>
52
52
  <a class="stat-tile" href="#operations"><span class="stat-tile__label">Sessions</span><span class="stat-tile__num" id="att-sessions">—</span><span class="stat-tile__sub">recorded, on the Operations tab</span></a>
53
53
  </div>
54
54
 
@@ -66,13 +66,6 @@
66
66
  <div id="gate-body"><span class="kit-skel"></span></div>
67
67
  </section>
68
68
 
69
- <!-- 2. ROSTER -->
70
- <section class="scroll" id="roster-scroll" aria-labelledby="roster-h" hidden>
71
- <h2 id="roster-h" class="scroll__h">Roster</h2>
72
- <p class="scroll__lede">Every builder in {{worldName}} — rank, {{currency}}, karma, and active claims.</p>
73
- <div id="roster-body"><span class="kit-skel"></span></div>
74
- </section>
75
-
76
69
  <!-- 3. REVIEWS BY BUILDER -->
77
70
  <section class="scroll" id="marks-scroll" aria-labelledby="marks-h" hidden>
78
71
  <h2 id="marks-h" class="scroll__h">Reviews by builder</h2>
@@ -175,6 +168,6 @@
175
168
  <script src="/builders/hall-kit.js"></script>
176
169
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
177
170
  <script src="/builders/brand-apply.js?v=2026-06-19-r56"></script>
178
- <script src="/builders/watch.js?v=2026-09-02-ledgers"></script>
171
+ <script src="/builders/watch.js?v=2026-11-05-roster"></script>
179
172
  </body>
180
173
  </html>
@@ -3,7 +3,8 @@
3
3
  // Metic+ monitoring view (Archon-only until ADR 0157 delegated the operational
4
4
  // permissions to the working rank). One self-contained page that stitches together
5
5
  // the oversight surfaces: pending access requests, security (red-team reports +
6
- // override queue + audit log), the builder roster, per-builder + project-wide
6
+ // override queue + audit log), the stale-claim tally (the roster itself is the
7
+ // Builders page since task 1005048), per-builder + project-wide
7
8
  // review grades, estimation drift, repo health, and a tally of recorded sessions.
8
9
  //
9
10
  // Note the page floor and the panel floors are no longer identical: the page and
@@ -165,7 +166,7 @@
165
166
  // NOTE: loadMarks is the deliberate outlier — its failure state is a plain
166
167
  // "empty" message, not "unavailable", so it keeps its own try/catch.
167
168
  // sealedText — name the floor the DENIAL reported, never one written here.
168
- // Each `sealedMsg` below is the SUBJECT clause only ("The roster is not visible
169
+ // Each `sealedMsg` below is the SUBJECT clause only ("The audit log is not visible
169
170
  // at your rank."); the rank sentence in front of it is composed from the 403.
170
171
  // That is what keeps this page from telling a builder a permission model the
171
172
  // server stopped implementing, which is the whole of task 1003449 — and why the
@@ -865,164 +866,19 @@
865
866
  }
866
867
 
867
868
  // =========================================================================
868
- // 3. ROSTER — every builder
869
+ // 3. STALE CLAIMS — a tally only (task 1005048)
869
870
  // =========================================================================
870
871
 
871
- // Live rank options for the per-row <select>. The server validates against
872
- // db.RANKS; these are the live, builder-facing tiers (ADR 0034). If a builder
873
- // sits at a rank outside this set, we inject it so the select still shows it.
874
- const RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
875
-
876
- // Who is reading this roster (task 1003720). `load()` already fetches /me to
877
- // resolve the page's floor and then threw the id away, so the roster — which
878
- // returns EVERY builder, the viewer included — could not tell which row was
879
- // the reader's own. See renderRoster for why that mattered.
880
- let viewerId = null;
881
- const isViewer = (b) => viewerId != null && b && b.id != null && String(b.id) === String(viewerId);
882
-
883
- function renderRoster(data, target) {
884
- const builders = Array.isArray(data.builders) ? data.builders : [];
885
- setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
886
- target.innerHTML = '';
887
- if (!builders.length) {
888
- target.innerHTML = emptyStateHtml('No builders on the roster.');
889
- return;
890
- }
891
- ledger(target, {
892
- columns: [
893
- 'Builder', 'Rank',
894
- { label: (window.OTBBranding?.currencyLabel?.({ caps: true }) ?? 'Credits'), class: 'num' },
895
- { label: 'Karma', class: 'num' }, { label: 'Active claims', class: 'num' },
896
- 'Standing', 'Action',
897
- ],
898
- pager: makePager('watch-roster', 'otb.watch.rosterPerPage'),
899
- rowHtml: (b) => {
900
- // THE VIEWER'S OWN ROW OFFERS NOTHING (task 1003720, re-filed from 1002951
901
- // — "I cannot press offboard", reported by the owner from Discord).
902
- //
903
- // The roster returns every builder, so the reader's own row is in it. Both
904
- // routes behind that row refuse a self-change OUTRIGHT and always will:
905
- // PATCH /builders/:id/rank answers cannot_change_own_rank and
906
- // /builders/:id/status answers cannot_change_own_status — the guard that
907
- // stops the last Archon demoting or offboarding themselves and locking
908
- // everyone out. The row painted a live rank <select> and a live Offboard
909
- // pill anyway, so from the owner's seat the page carried a control that
910
- // could only ever answer with a raw refusal slug in a toast. That is what
911
- // "I cannot press offboard" describes.
912
- //
913
- // This is not a SOFT gate (the primer's acknowledge control, which opens
914
- // once you have read enough and so stays in the tab order as
915
- // aria-disabled) — it is an action that can never open for this reader.
916
- // So the row renders no control at all and the section's foot line says
917
- // why, rather than painting a dead one.
918
- const self = isViewer(b);
919
- const opts = RANK_OPTIONS.slice();
920
- if (b.rank && !opts.includes(b.rank)) opts.unshift(b.rank);
921
- const rankCell = self
922
- ? `<span class="watch-self">${escapeHtml(rankLabel(b.rank))}</span>`
923
- : `<select data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}" aria-label="Rank">` +
924
- opts.map((rk) => `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`).join('') +
925
- '</select>';
926
- const isInactive = String(b.status) === 'inactive';
927
- const statusBtn = isInactive
928
- ? ghost(`data-roster-status="active" data-id="${escapeHtml(b.id)}"`, 'Reactivate')
929
- : ghost(`data-roster-status="inactive" data-id="${escapeHtml(b.id)}"`, 'Offboard', true);
930
- const actionCell = self
931
- ? '<span class="watch-self">That\'s you</span>'
932
- : `<div class="watch-actions">${statusBtn}</div>`;
933
- const claims = num(b.active_claim_count);
934
- const staleFlag = b.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
935
- // task 1467: the roster name links to the builder's public profile page.
936
- const rosterName = escapeHtml(b.display_name || b.github_login || ('#' + b.id));
937
- const rosterNameCell = b.id != null
938
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${rosterName}</a>`
939
- : rosterName;
940
- return (
941
- '<tr>' +
942
- `<td>${rosterNameCell}` +
943
- (b.github_login ? ` <span class="ov-mono">@${escapeHtml(b.github_login)}</span>` : '') +
944
- '</td>' +
945
- `<td>${rankCell}</td>` +
946
- `<td class="num">${fmtInt(b.total_credits)}</td>` +
947
- `<td class="num">${fmtInt(b.karma)}</td>` +
948
- `<td class="num${b.stale_claim ? ' warn' : ''}">${fmtInt(claims)}${staleFlag}</td>` +
949
- `<td><span class="badge badge--status">${escapeHtml(b.status || '—')}</span></td>` +
950
- `<td>${actionCell}</td>` +
951
- '</tr>'
952
- );
953
- },
954
- }).render(builders);
955
-
956
- // The one asymmetry in the table, stated once under it rather than repeated
957
- // on the row. Appended as an ELEMENT, not with `innerHTML +=`: re-parsing the
958
- // body would drop the pager's own wiring. The rank is written from
959
- // rankLabel(), never a hardcoded "Archon", so the floor cannot drift.
960
- if (builders.some(isViewer)) {
961
- const note = document.createElement('p');
962
- note.className = 'ov-foot';
963
- note.textContent = `Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last ${rankLabel('archon')} locking everyone out — so another ${rankLabel('archon')} does it.`;
964
- target.appendChild(note);
965
- }
966
-
967
- // Rank change via the <select>. Bespoke: a 'change' listener that rolls the
968
- // select back to its previous value on failure, delegated so a page flip
969
- // cannot strand a row without one.
970
- if (!target['wired:rank']) {
971
- target['wired:rank'] = true;
972
- target.addEventListener('change', async (e) => {
973
- const sel = e.target.closest('[data-roster-rank]');
974
- if (!sel || !target.contains(sel)) return;
975
- const id = sel.getAttribute('data-id');
976
- const rank = sel.value;
977
- const prev = sel.getAttribute('data-current');
978
- if (rank === prev) return;
979
- sel.disabled = true;
980
- try {
981
- await patchJson(`/builders/${encodeURIComponent(id)}/rank`, { rank });
982
- toast(`Rank set to ${rankLabel(rank)}.`);
983
- await loadRoster();
984
- } catch (err) {
985
- if (err && err.kind === '401') return;
986
- sel.value = prev; // roll back the select on failure
987
- sel.disabled = false;
988
- toast(`Could not change rank: ${err.message || err}`);
989
- }
990
- });
991
- }
992
-
993
- // Status change (offboard / reactivate). Bespoke: it gathers an offboarding
994
- // reason via window.prompt BEFORE disabling, so not delegate().
995
- if (!target['wired:status']) {
996
- target['wired:status'] = true;
997
- target.addEventListener('click', async (e) => {
998
- const btn = e.target.closest('[data-roster-status]');
999
- if (!btn || !target.contains(btn)) return;
1000
- const id = btn.getAttribute('data-id');
1001
- const status = btn.getAttribute('data-roster-status');
1002
- let reason = null;
1003
- if (status === 'inactive') {
1004
- reason = window.prompt('Reason for offboarding (optional):', '') || null;
1005
- }
1006
- btn.disabled = true;
1007
- try {
1008
- await patchJson(`/builders/${encodeURIComponent(id)}/status`, { status, reason });
1009
- toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.');
1010
- await loadRoster();
1011
- } catch (err) {
1012
- if (err && err.kind === '401') return;
1013
- btn.disabled = false;
1014
- toast(`Could not change standing: ${err.message || err}`);
1015
- }
1016
- });
1017
- }
1018
- }
1019
-
1020
- function loadRoster() {
1021
- return section($('#roster-body'), () => fetchJson('/builders/roster'), renderRoster, {
1022
- sealedMsg: 'The roster is not visible at your rank.',
1023
- errMsg: 'Could not load the roster.',
1024
- name: 'roster',
1025
- });
872
+ // The roster that lived here is the Builders page now (owner decision on idea
873
+ // 1001313: one list of builders). The attention tile keeps its count, read
874
+ // from the same route, and links to that list filtered to the stale claims.
875
+ function loadStaleCount() {
876
+ return fetchJson('/builders/roster')
877
+ .then((data) => {
878
+ const builders = Array.isArray(data && data.builders) ? data.builders : [];
879
+ setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
880
+ })
881
+ .catch(() => setAttention('att-stale', null));
1026
882
  }
1027
883
 
1028
884
  // =========================================================================
@@ -1411,7 +1267,7 @@
1411
1267
 
1412
1268
  function revealSections() {
1413
1269
  [
1414
- 'watch-attention', 'watch-tabs', 'gate-scroll', 'watch-scroll', 'roster-scroll',
1270
+ 'watch-attention', 'watch-tabs', 'gate-scroll', 'watch-scroll',
1415
1271
  'marks-scroll', 'mason-marks-scroll', 'surveyor-scroll', 'ledger-scroll', 'sessions-scroll',
1416
1272
  ].forEach((id) => {
1417
1273
  const el = document.getElementById(id);
@@ -1441,11 +1297,6 @@
1441
1297
  if (!res.ok) throw new Error(`me → ${res.status}`);
1442
1298
  const payload = res.data;
1443
1299
  builder = (payload && payload.builder) ? payload.builder : payload;
1444
- // Kept for the roster, which must know which row is the reader's own
1445
- // (task 1003720). Null when /me answered without one — isViewer then
1446
- // matches nothing, so the roster degrades to the old every-row-acts shape
1447
- // rather than marking the wrong row as yours.
1448
- viewerId = (builder && builder.id != null) ? builder.id : null;
1449
1300
  } catch (err) {
1450
1301
  console.error('[watch] failed to read viewer', err);
1451
1302
  showSealed('Could not check your access. Reload to try again.', 'Could not load');
@@ -1454,7 +1305,7 @@
1454
1305
  }
1455
1306
 
1456
1307
  // ADR 0157: Watch sits at the `page.view.government` floor (metic), and every panel
1457
- // it reads (roster, security reports, audit log, sessions, box costs) delegated to
1308
+ // it reads (stale claims, security reports, audit log, sessions, box costs) delegated to
1458
1309
  // metic with it. Cosmetic — requireGovernmentPage + the route gates are the wall.
1459
1310
  if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
1460
1311
  showSealed(`Available from the ${rankLabel('metic')} rank up. Your current rank does not grant access.`);
@@ -1469,7 +1320,7 @@
1469
1320
  loadEngagementDocs();
1470
1321
  loadOverrides();
1471
1322
  loadAudit();
1472
- loadRoster();
1323
+ loadStaleCount();
1473
1324
  loadMarks();
1474
1325
  loadMasonMarks();
1475
1326
  loadSurveyor();
@@ -37,3 +37,5 @@ The owner's Studio idea 1001297 (a circuit-trace image as a table divider; mock
37
37
  - **The pseudo-elements sit on the row's first `td`, never on the `tr`.** On the `tr` they became two extra table cells and pushed every cell in the row two columns right (seen in the first render). The `tr` only carries `position: relative`, so the `td`'s absolute children size against the whole row.
38
38
  - **Two masks, two tokens.** Traces are `--ink-faint` and the orange nodes `--accent`, each shown through an SVG mask, so light and dark follow the world with no hard-coded colour. Left cap, right cap and the stretching hairline are three layers of one mask.
39
39
  - **The empty-state row has no trace** (`:not(:has(.ledger__empty))`). Checked at desktop and 375px, light and dark: the table scrolls inside its card on a phone and the divider spans it; the page does not scroll sideways.
40
+
41
+ **Since task 1005048 it is the one list.** Watch's own roster folded in: a viewer the `GET /builders/roster` route lets in also sees active claims, the stale flag, standing and the rank/offboard controls, and the route's refusal leaves this page exactly the public list described above.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.39",
3
+ "version": "1.24.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.39",
9
+ "version": "1.24.40",
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.24.39",
3
+ "version": "1.24.40",
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",
@@ -11197,5 +11197,11 @@
11197
11197
  "id": "1005077",
11198
11198
  "text": "The governor street is now a work-first square: the working areas left the street, the extra header and footer are gone, a pin takes you straight into its building, and inside a building the drawing is a compact floor picker s"
11199
11199
  }
11200
+ ],
11201
+ "1.24.40": [
11202
+ {
11203
+ "id": "1005048",
11204
+ "text": "The Builders page is now the one list of everyone who builds here. If you hold a government seat you also see each builder's active claims, any stale claim, their standing, and the controls to change a rank or offboard someone"
11205
+ }
11200
11206
  ]
11201
11207
  }
@@ -146,7 +146,9 @@ const discordIsLinked = (builderId) =>
146
146
  // buttons for a viewer who cannot vote on ideas, without re-deriving it from rank.
147
147
  // `blocker.review` (task 1004824): the Blockers page and its overlay offer Resolve and
148
148
  // Link only to a builder who holds it; POST /blockers/:id/{resolve,link} stays the wall.
149
- const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open', 'idea.vote', 'blocker.review']);
149
+ // `builder.roster.read` (task 1005048): the Builders page asks GET /builders/roster
150
+ // for its government columns only of a viewer who holds it; the route stays the wall.
151
+ const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open', 'idea.vote', 'blocker.review', 'builder.roster.read']);
150
152
 
151
153
  // navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
152
154
  // or null when authority could not be established (government off, port missing,
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.39'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -93,7 +93,9 @@ const STATIC_LADDER_FILES = [
93
93
  'modules/discord/discord-channels.js',
94
94
  'modules/memory/search.js',
95
95
  'modules/hall-ui/public/profile.js',
96
- 'modules/hall-ui/public/watch.js',
96
+ // task 1005048: the rank <select> on each roster row moved from watch.js to the
97
+ // Builders page with the roster itself; the copy moved with it.
98
+ 'modules/hall-ui/public/roster.js',
97
99
  // task 1003094: the Constitution panel and the Board Room's sitting card both
98
100
  // expand the `rank:<key>+` membership form ("that rank and above") to render
99
101
  // who governs in plain language, so both need the ladder to expand against.