@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.
- package/.bongos-core.json +28 -28
- package/docs/copy-inventory.md +47 -48
- package/docs/copy-registry.json +75 -84
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +100 -107
- package/modules/hall-ui/public/roster.css +10 -0
- package/modules/hall-ui/public/roster.html +4 -1
- package/modules/hall-ui/public/roster.js +168 -9
- package/modules/hall-ui/public/watch.css +0 -6
- package/modules/hall-ui/public/watch.html +3 -10
- package/modules/hall-ui/public/watch.js +17 -166
- package/modules/hall-ui/records/builders-roster.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/routes/me.js +3 -1
- package/src/module-api.js +1 -1
- package/tests/rank_tier_single_source.mjs +3 -1
- package/tests/roster_government_columns.mjs +369 -0
- package/tests/watch_sealed_floor.mjs +6 -4
- package/tests/watch_roster_self_row.mjs +0 -296
|
@@ -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">' +
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
346
|
-
marks = window.OTBFounding ? window.OTBFounding.byId(
|
|
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,
|
|
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="
|
|
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-
|
|
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
|
|
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
|
|
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.
|
|
869
|
+
// 3. STALE CLAIMS — a tally only (task 1005048)
|
|
869
870
|
// =========================================================================
|
|
870
871
|
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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',
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|