@bongos/core 1.24.39 → 1.24.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.bongos-core.json +87 -102
  2. package/docs/canonical-permissions.md +2 -1
  3. package/docs/copy-inventory.md +122 -127
  4. package/docs/copy-registry.json +179 -244
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +11 -69
  7. package/docs/page-readings.json +434 -822
  8. package/modules/hall-ui/public/ai-studio.html +2 -2
  9. package/modules/hall-ui/public/atlas.html +2 -2
  10. package/modules/hall-ui/public/blockers.html +2 -2
  11. package/modules/hall-ui/public/board-room.html +2 -2
  12. package/modules/hall-ui/public/city.html +2 -2
  13. package/modules/hall-ui/public/collab.html +2 -2
  14. package/modules/hall-ui/public/commands.html +2 -2
  15. package/modules/hall-ui/public/copy-desk.html +2 -2
  16. package/modules/hall-ui/public/craft-mode.js +1 -1
  17. package/modules/hall-ui/public/deploy.html +2 -2
  18. package/modules/hall-ui/public/gate.js +12 -5
  19. package/modules/hall-ui/public/goal-map.html +2 -2
  20. package/modules/hall-ui/public/goals.html +2 -2
  21. package/modules/hall-ui/public/government.html +2 -2
  22. package/modules/hall-ui/public/idea.html +2 -2
  23. package/modules/hall-ui/public/ideas.html +2 -2
  24. package/modules/hall-ui/public/index.html +2 -2
  25. package/modules/hall-ui/public/market-watch-item.html +2 -2
  26. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  27. package/modules/hall-ui/public/market-watch.html +2 -2
  28. package/modules/hall-ui/public/modules.html +2 -2
  29. package/modules/hall-ui/public/money.html +2 -2
  30. package/modules/hall-ui/public/my-agents.html +2 -2
  31. package/modules/hall-ui/public/palette.js +10 -0
  32. package/modules/hall-ui/public/primer.html +2 -2
  33. package/modules/hall-ui/public/profile.html +2 -2
  34. package/modules/hall-ui/public/project-settings.html +2 -2
  35. package/modules/hall-ui/public/roadmap.html +2 -2
  36. package/modules/hall-ui/public/roster.css +10 -0
  37. package/modules/hall-ui/public/roster.html +6 -3
  38. package/modules/hall-ui/public/roster.js +168 -9
  39. package/modules/hall-ui/public/sessions.js +21 -10
  40. package/modules/hall-ui/public/settings.html +2 -2
  41. package/modules/hall-ui/public/shell.js +1 -2
  42. package/modules/hall-ui/public/street.html +2 -2
  43. package/modules/hall-ui/public/studio.html +2 -2
  44. package/modules/hall-ui/public/task.html +2 -2
  45. package/modules/hall-ui/public/thinking.html +2 -2
  46. package/modules/hall-ui/public/tweak-editor.html +2 -2
  47. package/modules/hall-ui/public/watch.css +0 -6
  48. package/modules/hall-ui/public/watch.html +121 -15
  49. package/modules/hall-ui/public/watch.js +28 -166
  50. package/modules/hall-ui/public/watch.states.json +49 -1
  51. package/modules/hall-ui/public/work.html +2 -2
  52. package/modules/hall-ui/records/builders-roster.md +2 -0
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +12 -0
  56. package/scripts/bongos/gen-hall-sitemap.js +2 -0
  57. package/src/bongos/routes/me.js +3 -1
  58. package/src/bongos/serve-internal.js +15 -22
  59. package/src/module-api.js +1 -1
  60. package/tests/autonomy_builder_scope.mjs +2 -1
  61. package/tests/hall_audit.mjs +1 -1
  62. package/tests/hall_loading.mjs +13 -2
  63. package/tests/hall_nav.mjs +5 -2
  64. package/tests/hall_oversight_tabs.mjs +162 -0
  65. package/tests/hall_oversight_world.mjs +23 -9
  66. package/tests/hall_page_gate_map.mjs +7 -2
  67. package/tests/hall_roster_world.mjs +14 -9
  68. package/tests/hall_sessions_ledger.mjs +2 -1
  69. package/tests/nav_permission_atoms.mjs +3 -2
  70. package/tests/rank_tier_single_source.mjs +3 -1
  71. package/tests/roster_government_columns.mjs +369 -0
  72. package/tests/sessions_kit_pager.mjs +8 -4
  73. package/tests/watch_sealed_floor.mjs +6 -4
  74. package/modules/hall-ui/public/gate.html +0 -76
  75. package/modules/hall-ui/public/gate.states.json +0 -60
  76. package/modules/hall-ui/public/sessions.html +0 -128
  77. package/modules/hall-ui/public/sessions.states.json +0 -59
  78. package/tests/watch_roster_self_row.mjs +0 -296
@@ -0,0 +1,369 @@
1
+ // tests/roster_government_columns.mjs — the Builders page's government columns,
2
+ // EXECUTED (task 1005048; the own-row rule is task 1003720, re-filed from task
3
+ // 1002951).
4
+ //
5
+ // Watch kept its own roster of the same builders the Builders page lists. The
6
+ // owner's call (idea 1001313) was one list, on Builders: a viewer the server
7
+ // lets read GET /builders/roster also sees each builder's active claims, the
8
+ // stale-claim flag and standing, and the rank and offboard controls that list
9
+ // carried. Anyone that read refuses sees the public list and nothing more.
10
+ //
11
+ // The own-row rule moved with the controls. The bug, as the owner reported it
12
+ // from Discord #bugs: "I cannot press offboard". Both routes behind a row
13
+ // refuse a self-change categorically and always will:
14
+ //
15
+ // PATCH /builders/:id/rank → 403 cannot_change_own_rank
16
+ // PATCH /builders/:id/status → 403 cannot_change_own_status
17
+ //
18
+ // so the viewer's own row renders no control and the foot line says why.
19
+ //
20
+ // These are assertions about what a human sees and clicks, so this runs the
21
+ // real roster.js inside `vm` against the real roster.html, with a makeLedger
22
+ // that actually calls rowHtml.
23
+ //
24
+ // Run: node tests/roster_government_columns.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import vm from 'node:vm';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { makeRunner, makeEl, parseInto } from './helpers.mjs';
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
35
+ const SRC = fs.readFileSync(path.join(HALL, 'roster.js'), 'utf8');
36
+ const PAGE = fs.readFileSync(path.join(HALL, 'roster.html'), 'utf8');
37
+ const WATCH_JS = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
38
+ const WATCH_HTML = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
39
+
40
+ const { test, summary } = makeRunner();
41
+
42
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
43
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
44
+
45
+ const tick = () => new Promise((r) => setTimeout(r, 0));
46
+ async function settle() { for (let i = 0; i < 40; i++) await tick(); }
47
+
48
+ const ok = (data) => ({ status: 200, ok: true, data });
49
+ const denied = { status: 403, ok: false, data: { error: { code: 'forbidden', details: { floors: { 'builder.roster.read': 'metic' } } } } };
50
+
51
+ // The branding pack renames every rank (the neutral pack calls archon "Owner"),
52
+ // which is why a hardcoded rank word in page copy is banned. This stub renames
53
+ // them too, so a test for "the foot line does not spell Archon" is real.
54
+ const RANK_LABELS = { xenos: 'Newcomer', thetes: 'Contributor', metic: 'Trusted', archon: 'Owner' };
55
+ const rankLabel = (r) => RANK_LABELS[r] || String(r || '');
56
+
57
+ // The viewer is builder 1. The public list holds the builders who have earned;
58
+ // the government roster holds every builder, Epsilon (nothing earned) included.
59
+ const VIEWER_ID = '1';
60
+ const PUBLIC = [
61
+ { id: '90', display_name: 'Alpha', github_login: 'alpha', rank: 'archon', total_credits: 9200, karma: 4, tokens_used: 10 },
62
+ { id: '1', display_name: 'The Owner', github_login: 'theowner', rank: 'archon', total_credits: 6896, karma: 68, tokens_used: 10 },
63
+ { id: '92', display_name: 'Gamma', github_login: 'gamma', rank: 'thetes', total_credits: 830, karma: 1, tokens_used: 10 },
64
+ { id: '93', display_name: 'Delta', github_login: 'delta', rank: 'xenos', total_credits: 40, karma: 0, tokens_used: 10 },
65
+ ];
66
+ const ROSTER = [
67
+ { id: '90', display_name: 'Alpha', github_login: 'alpha', rank: 'archon', total_credits: 9200, karma: 4, active_claim_count: 1, stale_claim: false, status: 'active' },
68
+ { id: '1', display_name: 'The Owner', github_login: 'theowner', rank: 'archon', total_credits: 6896, karma: 68, active_claim_count: 2, stale_claim: false, status: 'active' },
69
+ { id: '92', display_name: 'Gamma', github_login: 'gamma', rank: 'thetes', total_credits: 830, karma: 1, active_claim_count: 3, stale_claim: true, status: 'active' },
70
+ { id: '93', display_name: 'Delta', github_login: 'delta', rank: 'xenos', total_credits: 40, karma: 0, active_claim_count: 0, stale_claim: false, status: 'inactive' },
71
+ { id: '94', display_name: 'Epsilon', github_login: 'epsilon', rank: 'xenos', total_credits: 0, karma: 0, active_claim_count: 0, stale_claim: false, status: 'active' },
72
+ ];
73
+
74
+ // ===========================================================================
75
+ // Boot the real page against a scripted API and hand back handles.
76
+ // ===========================================================================
77
+
78
+ async function boot(opts = {}) {
79
+ const {
80
+ builders = ROSTER,
81
+ publicRows = PUBLIC,
82
+ rosterAnswer = null,
83
+ me = { id: VIEWER_ID, rank: 'archon', github_login: 'theowner' },
84
+ permissions = { 'builder.roster.read': true },
85
+ sharedMe = false,
86
+ search = '',
87
+ } = opts;
88
+
89
+ const requests = [];
90
+ const toasts = [];
91
+ const prompts = [];
92
+ let filterBar = null;
93
+
94
+ const api = {
95
+ async request(method, p, init) {
96
+ const body = init && init.body;
97
+ requests.push({ method, path: p, body });
98
+ const route = String(p).replace('/api/bongos', '').split('?')[0];
99
+ if (method === 'GET' && route === '/me') return ok({ builder: me, permissions });
100
+ if (method === 'GET' && route === '/builders/roster') return rosterAnswer || ok({ builders });
101
+ if (method === 'PATCH' && route.startsWith('/builders/')) return ok({ builder: { id: route.split('/')[2] } });
102
+ return { status: 404, ok: false, data: null };
103
+ },
104
+ };
105
+
106
+ const documentObj = {
107
+ readyState: 'complete',
108
+ body: makeEl('body'),
109
+ documentElement: makeEl('html'),
110
+ createElement: (tag) => makeEl(tag),
111
+ getElementById(id) { return this.body.querySelector(`#${id}`); },
112
+ querySelector(sel) { return this.body.querySelector(sel); },
113
+ querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
114
+ addEventListener() {},
115
+ };
116
+ parseInto(documentObj.body, PAGE);
117
+
118
+ const locationObj = { hostname: 'builders.example.test', pathname: '/roster', search, hash: '', assign() {} };
119
+
120
+ const kit = {
121
+ esc: escapeHtml,
122
+ makePager: () => ({ page: 1, perPage: 25 }),
123
+ makeFilterBar: (o) => { filterBar = o; o.mount.innerHTML = '<div class="filters"></div>'; return { el: o.mount, values: () => ({}) }; },
124
+ // A ledger that REALLY renders: the whole question here is which row got
125
+ // which cell, and a no-op render answers none of it.
126
+ makeLedger: ({ mount, columns, rowHtml }) => ({
127
+ render(rows) {
128
+ mount.innerHTML =
129
+ '<table class="ledger"><thead><tr>' +
130
+ columns.map((c) => `<th>${escapeHtml(typeof c === 'string' ? c : c.label)}</th>`).join('') +
131
+ '</tr></thead><tbody>' + rows.map((r) => rowHtml(r)).join('') + '</tbody></table>';
132
+ },
133
+ }),
134
+ };
135
+
136
+ const windowObj = {
137
+ OTB: { fmtInt: (n) => String(n == null ? 0 : n), rankLabel, toast: (m) => toasts.push(m) },
138
+ OTBKit: kit,
139
+ OTBBranding: { currencyLabel: () => 'Credits' },
140
+ BongosClient: { createClient: () => api },
141
+ location: locationObj,
142
+ prompt: (msg) => { prompts.push(msg); return 'left for a new job'; },
143
+ addEventListener() {},
144
+ };
145
+ // The shell's own /me read, shared on window.__hallReads (shell.js probeMe).
146
+ if (sharedMe) windowObj.__hallReads = { '/me': Promise.resolve({ builder: me, permissions }) };
147
+
148
+ const sandbox = {
149
+ window: windowObj, document: documentObj, location: locationObj,
150
+ fetch: async () => ({ ok: true, json: async () => ({ leaderboard: publicRows.map((b) => ({ ...b })) }) }),
151
+ console: { log() {}, warn() {}, error() {} },
152
+ setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN, URLSearchParams,
153
+ Number, String, Object, Array, Boolean, Set, Map, RegExp,
154
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
155
+ };
156
+ sandbox.globalThis = sandbox;
157
+ vm.createContext(sandbox);
158
+ vm.runInContext(SRC, sandbox, { filename: 'roster.js' });
159
+ await settle();
160
+
161
+ const rosterReads = () => requests.filter((r) => /\/builders\/roster/.test(r.path));
162
+ const meReads = () => requests.filter((r) => /\/me$/.test(r.path));
163
+ const rosterBody = () => documentObj.getElementById('roster-body');
164
+ const heads = () => rosterBody().querySelectorAll('th').map((th) => th.textContent);
165
+ const rows = () => rosterBody().querySelectorAll('tr').filter((tr) => tr.querySelectorAll('td').length);
166
+ const rowFor = (login) => rows().find((tr) => tr.querySelectorAll('td')[1].textContent.includes('@' + login));
167
+ const patches = () => requests.filter((r) => r.method === 'PATCH');
168
+ const foot = () => documentObj.getElementById('roster-gov-foot');
169
+ const footText = () => (foot().hidden ? '' : foot().textContent);
170
+
171
+ return { rosterReads, meReads, requests, toasts, prompts, documentObj, rosterBody, heads, rows, rowFor, patches, foot, footText, settle, filterBar: () => filterBar };
172
+ }
173
+
174
+ const cells = (tr) => tr.querySelectorAll('td');
175
+ const GOV_HEADS = ['Active claims', 'Standing', 'Action'];
176
+
177
+ // ===========================================================================
178
+ // 1. One list: a government viewer sees the columns Watch's roster carried.
179
+ // ===========================================================================
180
+
181
+ await test('a government viewer sees active claims, standing and actions', async () => {
182
+ const h = await boot();
183
+ for (const head of GOV_HEADS) assert.ok(h.heads().includes(head), `missing column ${head}: ${h.heads()}`);
184
+ assert.ok(h.heads().includes('Credits'), 'the credits column went missing');
185
+ const gamma = cells(h.rowFor('gamma'));
186
+ assert.match(gamma[7].textContent, /^3/, 'Gamma\'s active claims are not shown');
187
+ assert.match(gamma[7].textContent, /stale/, 'Gamma\'s stale claim is not flagged');
188
+ assert.match(cells(h.rowFor('delta'))[8].textContent, /inactive/, 'Delta\'s standing is not shown');
189
+ });
190
+
191
+ await test('every builder is on the one list, even one who has earned nothing yet', async () => {
192
+ const h = await boot();
193
+ assert.equal(h.rows().length, ROSTER.length);
194
+ assert.ok(h.rowFor('epsilon'), 'a builder on the government roster but not the public list dropped off');
195
+ });
196
+
197
+ await test('a stale-claims link lands on the list filtered to them', async () => {
198
+ const h = await boot({ search: '?claims=stale' });
199
+ assert.deepEqual(h.rows().map((tr) => cells(tr)[1].textContent.match(/@(\w+)/)[1]), ['gamma']);
200
+ const control = h.filterBar().primary.find((c) => c.id === 'roster-claims');
201
+ assert.equal(control && control.value, 'stale', 'the claims filter does not show the filter the link asked for');
202
+ });
203
+
204
+ // ===========================================================================
205
+ // 2. A viewer without the seat sees no new columns.
206
+ // ===========================================================================
207
+
208
+ await test('a refused roster read draws the public list and nothing more', async () => {
209
+ const h = await boot({ rosterAnswer: denied });
210
+ for (const head of GOV_HEADS) assert.ok(!h.heads().includes(head), `a refused viewer sees ${head}`);
211
+ assert.equal(h.rows().length, PUBLIC.length);
212
+ assert.equal(h.rosterBody().querySelectorAll('[data-roster-rank]').length, 0);
213
+ assert.equal(h.rosterBody().querySelectorAll('[data-roster-status]').length, 0);
214
+ assert.equal(h.footText(), '', 'the government foot line showed to a refused viewer');
215
+ const claims = h.filterBar().primary.find((c) => c.id === 'roster-claims');
216
+ assert.ok(!claims, 'a refused viewer is offered a claims filter');
217
+ });
218
+
219
+ await test('a signed-out viewer sees the public list only', async () => {
220
+ const h = await boot({ rosterAnswer: { status: 401, ok: false, data: null } });
221
+ for (const head of GOV_HEADS) assert.ok(!h.heads().includes(head));
222
+ });
223
+
224
+ await test('a viewer without the permission is never asked for the roster', async () => {
225
+ // The public page pays no extra round trip for a viewer /me says lacks the seat.
226
+ const h = await boot({ permissions: { 'builder.roster.read': false } });
227
+ assert.equal(h.rosterReads().length, 0, 'the roster was requested for a viewer without builder.roster.read');
228
+ for (const head of GOV_HEADS) assert.ok(!h.heads().includes(head));
229
+ });
230
+
231
+ await test('a /me with no permission map asks for nothing more', async () => {
232
+ const h = await boot({ permissions: null });
233
+ assert.equal(h.rosterReads().length, 0);
234
+ });
235
+
236
+ await test('the shell\'s shared /me read is adopted, not asked twice', async () => {
237
+ const h = await boot({ sharedMe: true });
238
+ assert.equal(h.meReads().length, 0, 'roster.js asked /me although the shell had already');
239
+ for (const head of GOV_HEADS) assert.ok(h.heads().includes(head));
240
+ });
241
+
242
+ // ===========================================================================
243
+ // 3. The viewer's own row offers neither control (task 1003720).
244
+ // ===========================================================================
245
+
246
+ await test('the viewer\'s own row renders no Offboard button', async () => {
247
+ const h = await boot();
248
+ const mine = h.rowFor('theowner');
249
+ assert.ok(mine, 'the roster did not render the viewer\'s own row');
250
+ assert.equal(mine.querySelectorAll('[data-roster-status]').length, 0,
251
+ 'the viewer\'s own row still carries a status button the server refuses outright');
252
+ });
253
+
254
+ await test('the viewer\'s own row renders no rank select', async () => {
255
+ const h = await boot();
256
+ assert.equal(h.rowFor('theowner').querySelectorAll('[data-roster-rank]').length, 0,
257
+ 'the viewer\'s own row still carries a rank select the server refuses outright');
258
+ });
259
+
260
+ await test('the own row still SHOWS the rank and says whose row it is', async () => {
261
+ const h = await boot();
262
+ const c = cells(h.rowFor('theowner'));
263
+ assert.equal(c[3].textContent.trim(), 'Owner', 'the own row lost the rank it should still state');
264
+ assert.match(c[9].textContent, /you/i, 'the own row\'s action cell says nothing about why it is empty');
265
+ });
266
+
267
+ // ===========================================================================
268
+ // 4. Every OTHER row keeps both controls.
269
+ // ===========================================================================
270
+
271
+ await test('every other row keeps its Offboard / Reactivate button', async () => {
272
+ const h = await boot();
273
+ for (const login of ['alpha', 'gamma', 'delta']) {
274
+ assert.equal(h.rowFor(login).querySelectorAll('[data-roster-status]').length, 1, `row @${login} lost its status control`);
275
+ }
276
+ assert.equal(h.rowFor('delta').querySelector('[data-roster-status]').getAttribute('data-roster-status'), 'active');
277
+ assert.equal(h.rowFor('alpha').querySelector('[data-roster-status]').getAttribute('data-roster-status'), 'inactive');
278
+ });
279
+
280
+ await test('every other row keeps its rank select', async () => {
281
+ const h = await boot();
282
+ for (const login of ['alpha', 'gamma', 'delta']) {
283
+ assert.equal(h.rowFor(login).querySelectorAll('[data-roster-rank]').length, 1, `row @${login} lost its rank select`);
284
+ }
285
+ });
286
+
287
+ await test('another builder\'s Offboard still reaches the route', async () => {
288
+ const h = await boot();
289
+ const btn = h.rowFor('gamma').querySelector('[data-roster-status]');
290
+ // Delegated on #roster-body; the stub's fire() does not bubble, so dispatch
291
+ // on the mount and name the button as the target, the way a real click arrives.
292
+ h.rosterBody().fire('click', { target: btn });
293
+ await h.settle();
294
+ const p = h.patches();
295
+ assert.equal(p.length, 1, `expected one PATCH, got ${p.length}`);
296
+ assert.match(p[0].path, /\/builders\/92\/status$/);
297
+ assert.equal(p[0].body.status, 'inactive');
298
+ assert.equal(p[0].body.reason, 'left for a new job', 'the offboarding reason was dropped');
299
+ assert.equal(h.prompts.length, 1, 'the reason prompt no longer runs');
300
+ });
301
+
302
+ await test('a rank change reaches the route', async () => {
303
+ const h = await boot();
304
+ const sel = h.rowFor('gamma').querySelector('[data-roster-rank]');
305
+ sel.value = 'metic';
306
+ h.rosterBody().fire('change', { target: sel });
307
+ await h.settle();
308
+ const p = h.patches();
309
+ assert.equal(p.length, 1);
310
+ assert.match(p[0].path, /\/builders\/92\/rank$/);
311
+ assert.equal(p[0].body.rank, 'metic');
312
+ });
313
+
314
+ // ===========================================================================
315
+ // 5. The foot line says why, in the pack's own words.
316
+ // ===========================================================================
317
+
318
+ await test('a foot line under the roster explains the empty row', async () => {
319
+ const h = await boot();
320
+ assert.match(h.footText(), /your own row carries no controls/i,
321
+ 'nothing under the roster explains why one row has no controls');
322
+ });
323
+
324
+ await test('the foot line names the rank from the pack, never a hardcoded "Archon"', async () => {
325
+ const h = await boot();
326
+ const foot = h.footText();
327
+ assert.match(foot, /Owner/, 'the foot line does not name the rank through rankLabel()');
328
+ assert.ok(!/archon/i.test(foot), `the foot line hardcodes a rank word: ${foot}`);
329
+ });
330
+
331
+ await test('no own-row sentence when the viewer is not on the roster', async () => {
332
+ // A Metic reading the roster of a project they are not listed on.
333
+ const h = await boot({ builders: ROSTER.filter((b) => b.id !== VIEWER_ID), publicRows: PUBLIC.filter((b) => b.id !== VIEWER_ID) });
334
+ assert.ok(!/your own row/i.test(h.footText()), 'the foot line rendered with no own row on the table');
335
+ });
336
+
337
+ // ===========================================================================
338
+ // 6. Degrade: an unidentifiable viewer must not mark the WRONG row.
339
+ // ===========================================================================
340
+
341
+ await test('no id from /me leaves every row armed', async () => {
342
+ const h = await boot({ me: { rank: 'archon', github_login: 'theowner' } });
343
+ for (const b of PUBLIC) {
344
+ assert.equal(h.rowFor(b.github_login).querySelectorAll('[data-roster-status]').length, 1,
345
+ `row @${b.github_login} was disarmed although the viewer could not be identified`);
346
+ }
347
+ assert.ok(!/your own row/i.test(h.footText()));
348
+ });
349
+
350
+ await test('the match is by value, not by type — a numeric id still matches', async () => {
351
+ const h = await boot({ me: { id: 1, rank: 'archon', github_login: 'theowner' } });
352
+ assert.equal(h.rowFor('theowner').querySelectorAll('[data-roster-status]').length, 0,
353
+ 'a numeric /me id did not match the roster\'s string id — the own row is armed again');
354
+ });
355
+
356
+ // ===========================================================================
357
+ // 7. Watch has no second list; its tally links here.
358
+ // ===========================================================================
359
+
360
+ await test('Watch carries no roster of its own any more', () => {
361
+ assert.ok(!/id="roster-body"/.test(WATCH_HTML), 'watch.html still has a roster section');
362
+ assert.ok(!/data-roster-rank|data-roster-status/.test(WATCH_JS), 'watch.js still draws roster controls');
363
+ });
364
+
365
+ await test('Watch\'s stale-claims tile links to Builders, filtered', () => {
366
+ assert.match(WATCH_HTML, /<a class="stat-tile" href="\/roster\?claims=stale">[^]*?id="att-stale"/);
367
+ });
368
+
369
+ summary('Builders page — the government columns');
@@ -33,7 +33,8 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
33
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
34
34
  const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
35
35
  const SESSIONS_JS = read('modules/hall-ui/public/sessions.js');
36
- const SESSIONS_HTML = read('modules/hall-ui/public/sessions.html');
36
+ // task 1005049: the ledger is Watch's Sessions tab, so its markup lives in watch.html.
37
+ const SESSIONS_HTML = read('modules/hall-ui/public/watch.html');
37
38
 
38
39
  const { test, summary } = makeRunner();
39
40
  const tick = () => new Promise((r) => setTimeout(r, 0));
@@ -147,6 +148,9 @@ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPageP
147
148
  vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
148
149
  if (dropKit) delete sandbox.window.OTBKit;
149
150
  vm.runInContext(SESSIONS_JS, sandbox, { filename: 'sessions.js' });
151
+ // The tab boots when Watch first opens it (task 1005049); a second open is a no-op.
152
+ sandbox.window.OTBWatchTabs.sessions();
153
+ sandbox.window.OTBWatchTabs.sessions();
150
154
 
151
155
  const searches = () => requests.filter((u) => u.includes('/sessions/search'));
152
156
  const q = (n) => {
@@ -161,7 +165,7 @@ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPageP
161
165
  // 1. the page markup no longer carries a pager of its own
162
166
  // ---------------------------------------------------------------------------
163
167
 
164
- await test('sessions.html carries a mount, not a hand-rolled pager', () => {
168
+ await test('the Sessions tab carries a mount, not a hand-rolled pager', () => {
165
169
  assert.ok(!/id="ledger-pager"/.test(SESSIONS_HTML), 'the bespoke <nav class="pager"> is gone');
166
170
  assert.ok(!/id="pager-(prev|next|pos)"/.test(SESSIONS_HTML), 'its bespoke button ids are gone');
167
171
  assert.match(SESSIONS_HTML, /id="sessions-pager-mount"/);
@@ -307,14 +311,14 @@ await test('a missing kit degrades to a named failure, not a blank page', async
307
311
  await tick(); await tick();
308
312
  assert.equal(h.searches().length, 0, 'no corpus request without a pager to place it');
309
313
  // in the page body: the head that used to carry it is hidden (task 1004829)
310
- assert.match(h.getEl('gate-body').textContent, /could not be loaded/);
314
+ assert.match(h.getEl('sess-sealed-body').textContent, /could not be loaded/);
311
315
  });
312
316
 
313
317
  await test('the rank gate still runs before any corpus request', async () => {
314
318
  const h = harness({ total: 320, viewerRank: 'xenos' });
315
319
  await tick(); await tick();
316
320
  assert.equal(h.searches().length, 0, 'a xenos must not even ask');
317
- assert.equal(h.getEl('gate-scroll').hidden, false, 'they get the sealed notice');
321
+ assert.equal(h.getEl('sess-sealed').hidden, false, 'they get the sealed notice');
318
322
  });
319
323
 
320
324
  summary();
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // access_request.review → archon ("Archons only" was CORRECT here)
18
18
  // override_request.decide → metic (ADR 0157, was archon)
19
- // builder.roster.read → metic (ADR 0157, was archon)
19
+ // builder.roster.read → metic (ADR 0157, was archon; its panel is on Builders since task 1005048)
20
20
  // session.search → metic (ADR 0157, was archon)
21
21
  // audit_log.read → metic (ADR 0157, was archon)
22
22
  //
@@ -188,11 +188,12 @@ await test('two panels denied at DIFFERENT floors say different things', async (
188
188
  const h = await boot({
189
189
  answers: {
190
190
  '/access-requests': denied({ 'access_request.review': 'archon' }),
191
- '/builders/roster': denied({ 'builder.roster.read': 'metic' }),
191
+ '/audit-log': denied({ 'audit_log.read': 'metic' }),
192
192
  },
193
193
  });
194
+ // The roster panel that used to be the metic half moved to Builders (task 1005048).
194
195
  assert.match(h.sealed('gate-body'), /^Archon and up\./);
195
- assert.match(h.sealed('roster-body'), /^Metic and up\./);
196
+ assert.match(h.sealed('audit-body'), /^Metic and up\./);
196
197
  });
197
198
 
198
199
  // ===========================================================================
@@ -228,7 +229,8 @@ await test('no sealed message on this page hardcodes a rank name', async () => {
228
229
  // The source half of the claim: a future edit that re-writes "Archons only."
229
230
  // into a sealedMsg reds here even if it never renders in a test.
230
231
  const strings = [...SRC.matchAll(/sealedMsg:\s*'([^']*)'/g)].map((m) => m[1]);
231
- assert.ok(strings.length >= 10, `expected the page's sealed messages, found ${strings.length}`);
232
+ // Nine since the roster panel moved to Builders (task 1005048).
233
+ assert.ok(strings.length >= 9, `expected the page's sealed messages, found ${strings.length}`);
232
234
  for (const s of strings) {
233
235
  assert.doesNotMatch(s, /Archon|Metic|Thetes|Xenos/i,
234
236
  `a sealed message names a rank in its own text: "${s}". The rank comes from `
@@ -1,76 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Autobongos — {{worldName}}</title>
7
- <meta name="description" content="The Autobongos runner: switch unattended building on or off, choose the goals it may work, and pause any builder's runner.">
8
- <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
- <link rel="stylesheet" href="/builders/oversight.css">
13
- <link rel="stylesheet" href="/builders/gate.css">
14
- </head>
15
- <body data-page="gate" data-page-title="Autobongos">
16
-
17
- <div class="app">
18
- <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
19
- <div class="app-scrim" id="app-scrim" hidden></div>
20
- <div class="app-body">
21
- <header class="app-topbar" id="app-topbar"></header>
22
- <div class="app-content">
23
- <!-- The page's one h1, for screen readers: no page shows its head (task
24
- 1004836), so its sub-line went in task 1004829. -->
25
- <div class="page-head page-head--board">
26
- <h1 class="page-head__h">Autobongos</h1>
27
- </div>
28
-
29
- <main id="hall-main">
30
- <!-- task 1005043: the page loader, shown until any section below is revealed. -->
31
- <p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…</p>
32
- <!-- task 1003469: the sealed state is a panel with a heading, not a bare
33
- line on the ground. gate.js writes the real floor into the lede from
34
- the pack's rank label, so the copy cannot drift from ADR 0157. -->
35
- <section class="scroll" id="gate-sealed" aria-labelledby="gate-sealed-h" hidden>
36
- <h2 class="scroll__h" id="gate-sealed-h">Restricted</h2>
37
- <p class="scroll__lede" id="gate-sealed-body">This page opens at a higher rank.</p>
38
- </section>
39
-
40
- <section id="gate-body" hidden>
41
- <!-- The Autobongos fence (task 1003908, ADR 0307 §6): what is allowed to
42
- reach main unattended. This page held the held-PR list too until task
43
- 1004859 removed it; the URL stays /gate so bookmarks keep working.
44
- Archon-only — a Metic gets the note below instead, and the routes
45
- behind the panel are system:true/archon server-side regardless. -->
46
- <p class="scroll__lede" id="gate-archon-only" hidden></p>
47
- <section class="scroll" id="fence-panel" aria-labelledby="fence-h" hidden>
48
- <h2 class="scroll__h" id="fence-h">Autobongos runner</h2>
49
- <!-- One panel (task 1004509): a checklist while anything the runner
50
- needs is missing, one status line with the switch once it is all
51
- ticked. Drawn from /autonomy/runners + /autonomy/fence together. -->
52
- <div id="fence-body"><span class="kit-skel"></span></div>
53
- <!-- Every builder's runner (task 1004501): what each one picked to
54
- work, whether you paused it, and whether it is checking in. -->
55
- <div id="fence-builders"></div>
56
- </section>
57
- </section>
58
- </main>
59
-
60
- <footer class="app-foot" id="app-foot"></footer>
61
- </div>
62
- </div>
63
- </div>
64
-
65
- <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
66
-
67
- <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
68
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
69
- <script src="/builders/craft-mode.js?v=2026-11-05-sections"></script>
70
- <script src="/builders/shell.js?v=2026-11-05-sections"></script>
71
- <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
72
- <script src="/builders/palette.js" defer></script>
73
- <script src="/builders/hall-kit.js"></script>
74
- <script src="/builders/gate.js?v=2026-10-25-autobongos"></script>
75
- </body>
76
- </html>
@@ -1,60 +0,0 @@
1
- {
2
- "_": "Autobongos — the owner's runner panel behind requireGovernmentPage, still served at /gate (task 1004859 removed the held-PR list). Measured at three widths in both modes rather than looked at once.",
3
- "page": "/builders/gate",
4
- "surface": "hall-ui",
5
- "stub": {
6
- "prefix": "/builders/"
7
- },
8
- "modeQuery": false,
9
- "_feeds": "The kit's own stub carries no feed for this page, so against it the page renders its EMPTY and SKELETON states — which is the point: those are states the floors must hold in. Against the hall-preview harness the same states render populated. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
10
- "ignoreRequests": [
11
- "/api/(gds|bongos)/me$",
12
- "/api/(gds|bongos)/me/",
13
- "/api/(gds|bongos)/goals",
14
- "/api/(gds|bongos)/inbox/",
15
- "/api/(gds|bongos)/versions",
16
- "/api/(gds|bongos)/builders",
17
- "/api/(gds|bongos)/access-requests",
18
- "/api/(gds|bongos)/autonomy/"
19
- ],
20
- "ignoreConsole": [
21
- "/api/(gds|bongos)/",
22
- "\\b401\\b",
23
- "\\b403\\b",
24
- "\\b404\\b",
25
- "\\b501\\b",
26
- "Unauthorized",
27
- "Forbidden",
28
- "Not Found",
29
- "Not Implemented",
30
- "load failed"
31
- ],
32
- "states": {
33
- "queue": {
34
- "auth": true,
35
- "actions": [
36
- [
37
- "wait",
38
- 700
39
- ]
40
- ],
41
- "expect": {
42
- "visible": [
43
- "#gate-body"
44
- ]
45
- }
46
- },
47
- "out": {
48
- "auth": false
49
- }
50
- },
51
- "reducedMotion": {
52
- "auth": true,
53
- "actions": [
54
- [
55
- "wait",
56
- 600
57
- ]
58
- ]
59
- }
60
- }