@bongos/core 1.19.1012 → 1.19.1014

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 (76) hide show
  1. package/.bongos-core.json +113 -78
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +120 -66
  4. package/docs/copy-registry.json +984 -498
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +1 -1
  13. package/modules/hall-ui/public/diagrams.html +1 -1
  14. package/modules/hall-ui/public/drachmae.html +1 -1
  15. package/modules/hall-ui/public/fleet-costs.js +80 -0
  16. package/modules/hall-ui/public/fleet.html +64 -0
  17. package/modules/hall-ui/public/fleet.js +45 -0
  18. package/modules/hall-ui/public/fleet.states.json +14 -0
  19. package/modules/hall-ui/public/gate.html +1 -1
  20. package/modules/hall-ui/public/goal-map.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea-objects.js +4 -1
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/people.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/roster.html +1 -1
  35. package/modules/hall-ui/public/sessions.html +1 -1
  36. package/modules/hall-ui/public/settings.html +1 -1
  37. package/modules/hall-ui/public/shell.js +4 -0
  38. package/modules/hall-ui/public/sky-composer.js +155 -0
  39. package/modules/hall-ui/public/sky-data.js +287 -0
  40. package/modules/hall-ui/public/sky-draw.js +244 -0
  41. package/modules/hall-ui/public/sky-interaction.js +137 -0
  42. package/modules/hall-ui/public/sky-layout.js +81 -0
  43. package/modules/hall-ui/public/sky-panel.js +141 -0
  44. package/modules/hall-ui/public/sky-tour.js +80 -0
  45. package/modules/hall-ui/public/studio.html +1 -1
  46. package/modules/hall-ui/public/task.html +1 -1
  47. package/modules/hall-ui/public/thinking.css +203 -267
  48. package/modules/hall-ui/public/thinking.html +119 -58
  49. package/modules/hall-ui/public/thinking.states.json +33 -9
  50. package/modules/hall-ui/public/watch.html +1 -1
  51. package/modules/hall-ui/public/work.html +1 -1
  52. package/modules/provisioning/routes/provisioning.js +4 -4
  53. package/modules/public-landing/public/projects.html +7 -0
  54. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/src/bongos/auth.js +8 -0
  58. package/src/bongos/routes/me.js +4 -1
  59. package/src/bongos/serve-internal.js +25 -6
  60. package/src/module-api.js +1 -1
  61. package/tests/auth_resolve_failure.mjs +3 -2
  62. package/tests/fleet_cost_view.mjs +189 -0
  63. package/tests/hall_audit.mjs +27 -0
  64. package/tests/hall_nav.mjs +3 -1
  65. package/tests/hall_page_gate_map.mjs +13 -5
  66. package/tests/hall_sky.mjs +393 -405
  67. package/tests/hall_thinking_room.mjs +274 -296
  68. package/tests/helpers.mjs +185 -217
  69. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  70. package/tests/ideator_traceable_proof.mjs +61 -39
  71. package/tests/nav_permission_atoms.mjs +1 -1
  72. package/modules/hall-ui/public/sky.css +0 -365
  73. package/modules/hall-ui/public/sky.html +0 -79
  74. package/modules/hall-ui/public/sky.js +0 -580
  75. package/modules/hall-ui/public/sky.states.json +0 -20
  76. package/modules/hall-ui/public/thinking.js +0 -418
@@ -0,0 +1,189 @@
1
+ // tests/fleet_cost_view.mjs — Cost view v1 (task 1001949, ADR 0327): the fleet cost page
2
+ // for the platform owner, and the one estimate line on a cloud-host owner's project page.
3
+ //
4
+ // The rule every case below leans on: an UNPRICED cost (null) renders as unpriced and never
5
+ // as $0, and never moves a total. A $0 there would be a price nobody quoted.
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import http from 'node:http';
9
+ import vm from 'node:vm';
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath } from 'node:url';
13
+ import path from 'node:path';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
18
+ const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
19
+ const costs = require(path.join(HALL, 'fleet-costs.js'));
20
+ const FIXTURE = JSON.parse(read(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'provisioning__fleet__cost-ledger.json')));
21
+
22
+ // ── the fleet page's builders ────────────────────────────────────────────────
23
+ const row = (over) => ({ id: '1', slug: 'p', owner: 'o', hosting_shape: 'cloud-host', tier: null, status: 'active', platform_monthly_usd: 0, owner_monthly_usd: null, ...over });
24
+ const cells = (html) => [...html.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());
25
+ const foot = (html) => html.slice(html.indexOf('<tfoot>'));
26
+
27
+ test('money: whole dollars stay whole, cents show two places, and an unpriced value has no price', () => {
28
+ assert.equal(costs.money(13), '$13');
29
+ assert.equal(costs.money(12.5), '$12.50');
30
+ assert.equal(costs.money(0), '$0');
31
+ assert.equal(costs.money(null), null);
32
+ assert.equal(costs.money(undefined), null);
33
+ });
34
+
35
+ test('an unpriced row reads "not priced yet", never $0 — and a real $0 still reads $0', () => {
36
+ const html = costs.ledgerHtml({
37
+ instances: [row({ slug: 'a', platform_monthly_usd: 0, owner_monthly_usd: null })],
38
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 1 } },
39
+ });
40
+ const [, , , , us, owner] = cells(html.slice(0, html.indexOf('<tfoot>')));
41
+ assert.equal(us, '$0', 'a cloud-host costs us a real, measured $0');
42
+ assert.equal(owner, 'not priced yet');
43
+ });
44
+
45
+ test('a total sums only priced rows and names how many it left out', () => {
46
+ const html = costs.ledgerHtml({
47
+ instances: [
48
+ row({ slug: 'cp', hosting_shape: 'control-plane', tier: 's-1vcpu-2gb', platform_monthly_usd: 12, owner_monthly_usd: 0 }),
49
+ row({ slug: 'priced', tier: 'starter+basic-256mb', owner_monthly_usd: 13 }),
50
+ row({ slug: 'bare', owner_monthly_usd: null }),
51
+ ],
52
+ totals: { platform: { monthly_usd: 12, unpriced: 0 }, owner: { monthly_usd: 13, unpriced: 1 } },
53
+ });
54
+ const [us, owner] = cells(foot(html));
55
+ assert.equal(us, '$12');
56
+ assert.equal(owner, '$13 + 1 not priced', 'the null row is counted, not summed');
57
+ });
58
+
59
+ test('a column with NOTHING priced has no total — the sum of no prices is not $0', () => {
60
+ const html = costs.ledgerHtml({
61
+ instances: [row({ slug: 'a' }), row({ slug: 'b' })],
62
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 2 } },
63
+ });
64
+ const [us, owner] = cells(foot(html));
65
+ assert.equal(us, '$0', 'every row prices at a real $0 for us');
66
+ assert.equal(owner, 'not priced yet', 'the owner total is not printed as $0');
67
+ });
68
+
69
+ test('the harness fixture reads as live does today: our box priced, every owner bill but ours unpriced', () => {
70
+ const [us, owner] = cells(foot(costs.ledgerHtml(FIXTURE)));
71
+ assert.equal(us, '$12');
72
+ assert.equal(owner, '$0 + 2 not priced, 1 not visible');
73
+ });
74
+
75
+ test('the total says what its cells say: a self-hosted bill is "not visible", not "not priced"', () => {
76
+ const byo = (slug) => row({ slug, hosting_shape: 'byo-host' });
77
+ const mixed = costs.ledgerHtml({
78
+ instances: [row({ slug: 'p', tier: 'starter+basic-256mb', owner_monthly_usd: 13 }), byo('a'), byo('b')],
79
+ totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 13, unpriced: 2 } },
80
+ });
81
+ assert.equal(cells(foot(mixed))[1], '$13 + 2 not visible');
82
+ const allByo = costs.ledgerHtml({ instances: [byo('a'), byo('b')], totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 2 } } });
83
+ assert.equal(cells(foot(allByo))[1], 'not visible to us');
84
+ });
85
+
86
+ test('a self-hosted owner\'s bill is not ours to see — a different absence from an unrecorded plan', () => {
87
+ const html = costs.ledgerHtml({ instances: [row({ hosting_shape: 'byo-host' })], totals: { platform: { monthly_usd: 0, unpriced: 0 }, owner: { monthly_usd: 0, unpriced: 1 } } });
88
+ assert.equal(cells(html)[5], 'not visible to us');
89
+ assert.equal(cells(html)[2], 'Self-hosted');
90
+ });
91
+
92
+ test('every row of the fixture is listed, a missing plan says so, and row text is escaped', () => {
93
+ const html = costs.ledgerHtml(FIXTURE);
94
+ for (const r of FIXTURE.instances) assert.ok(html.includes(`>${r.slug}<`), `${r.slug} is a row`);
95
+ assert.match(html, /none recorded/);
96
+ const hostile = costs.ledgerHtml({ instances: [row({ slug: '<img src=x onerror=1>', owner: '"q"' })], totals: {} });
97
+ assert.doesNotMatch(hostile, /<img/);
98
+ assert.match(hostile, /&lt;img src=x onerror=1&gt;/);
99
+ });
100
+
101
+ test('no projects renders a sentence, not an empty table', () => {
102
+ assert.match(costs.ledgerHtml({ instances: [], totals: {} }), /No projects are hosted here yet/);
103
+ assert.match(costs.ledgerHtml(null), /No projects are hosted here yet/);
104
+ });
105
+
106
+ // ── the page: its wiring and its gate ────────────────────────────────────────
107
+ test('the page loads the builders before its chrome and reads the ledger route', () => {
108
+ const html = read(path.join(HALL, 'fleet.html'));
109
+ assert.ok(html.indexOf('fleet-costs.js') !== -1 && html.indexOf('fleet-costs.js') < html.indexOf('/fleet.js'), 'fleet-costs.js loads first');
110
+ const js = read(path.join(HALL, 'fleet.js'));
111
+ assert.match(js, /\/provisioning\/fleet\/cost-ledger/);
112
+ assert.match(js, /status === 403[\s\S]{0,200}fl-sealed/, 'a refusal shows the sealed state');
113
+ });
114
+
115
+ test('the page gate is the atom the ledger route checks, and the nav mirrors its floor', () => {
116
+ const route = read(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'));
117
+ assert.match(route, /router\.get\('\/provisioning\/fleet\/cost-ledger', auth\.requireBuilder, auth\.requirePermission\('provisioning\.fleet\.manage'\)/);
118
+ assert.match(read(path.join(ROOT, 'src', 'bongos', 'auth.js')), /requireFleetPage: Object\.freeze\(\{ permission: 'provisioning\.fleet\.manage'/);
119
+ // The nav is cosmetic (the page gate is the wall); 'government' is the metic floor.
120
+ const { byKey } = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
121
+ assert.equal(byKey('provisioning.fleet.manage').floor, 'metic');
122
+ assert.match(read(path.join(HALL, 'shell.js')), /href: '\/fleet', gate: 'government', module: 'provisioning'/);
123
+ });
124
+
125
+ // ── the owner's line on their own project page ───────────────────────────────
126
+ const HUB = read(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'));
127
+ function hubFn(name) {
128
+ const start = HUB.indexOf(`function ${name}(`);
129
+ assert.ok(start !== -1, `${name} is defined in projects.html`);
130
+ const end = HUB.indexOf('\n function ', start + 1);
131
+ const box = {};
132
+ vm.runInNewContext(`${HUB.slice(start, end)}\nbox.fn = ${name};`, { box });
133
+ return box.fn;
134
+ }
135
+
136
+ test('the owner\'s hosting-bill line: a price when the plan is known, "not priced yet" when not', () => {
137
+ const hostingBill = hubFn('hostingBill');
138
+ assert.equal(hostingBill(null), 'not priced yet');
139
+ assert.equal(hostingBill(undefined), 'not priced yet');
140
+ assert.equal(hostingBill(13), '$13/month, estimated. Render bills you directly.');
141
+ assert.equal(hostingBill(12.5), '$12.50/month, estimated. Render bills you directly.');
142
+ });
143
+
144
+ test('the line is shown only on a live cloud-host — a self-hosted owner\'s bill is theirs', () => {
145
+ const body = HUB.slice(HUB.indexOf('function renderManage('), HUB.indexOf("$('mMeta').innerHTML", HUB.indexOf('function renderManage(')));
146
+ assert.match(body, /if \(inst\.hosting_shape === 'cloud-host' && inst\.status !== 'torn_down'\) rows\.push\(\['hosting bill', hostingBill\(inst\.owner_monthly_usd\)\]\);/);
147
+ });
148
+
149
+ // ── the payload the owner's page reads ───────────────────────────────────────
150
+ const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
151
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
152
+ let detailRow = null;
153
+ provisioning.getInstanceById = async () => detailRow;
154
+ provisioning.getOpenIntent = async () => null;
155
+ provisioning.getLatestOAuthManifestForInstance = async () => null;
156
+ require(path.join(ROOT, 'modules', 'provisioning', 'catalog-bridge.js')).healInstanceCatalog = async () => {};
157
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
158
+ const express = require('express');
159
+ const app = express();
160
+ app.use((_req, res, next) => {
161
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } });
162
+ next();
163
+ });
164
+ app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'))());
165
+ const getJson = (url) => new Promise((resolve, reject) => {
166
+ http.get(url, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || 'null') })); }).on('error', reject);
167
+ });
168
+
169
+ test('GET /provisioning/instances/:id carries the owner\'s estimate — null until a plan is recorded', async () => {
170
+ const server = app.listen(0);
171
+ await new Promise((r) => server.once('listening', r));
172
+ const url = `http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances/41`;
173
+ const base = { id: 41, owner_builder_id: 7, slug: 'p', status: 'active', hosting_shape: 'cloud-host', description: 'd' };
174
+ try {
175
+ detailRow = { ...base, tier: null };
176
+ let r = await getJson(url);
177
+ assert.equal(r.status, 200);
178
+ assert.ok('owner_monthly_usd' in r.body.instance, 'the field is present');
179
+ assert.equal(r.body.instance.owner_monthly_usd, null, 'no plan: unpriced, not 0');
180
+ detailRow = { ...base, tier: 'starter+basic-256mb' };
181
+ r = await getJson(url);
182
+ assert.equal(r.body.instance.owner_monthly_usd, 13, 'the ledger\'s own price for the named plan');
183
+ detailRow = { ...base, owner_builder_id: 8, tier: 'starter+basic-256mb' };
184
+ r = await getJson(url);
185
+ assert.equal(r.status, 404, 'another builder\'s project answers nothing');
186
+ } finally {
187
+ server.close();
188
+ }
189
+ });
@@ -354,9 +354,22 @@ test('the module doc still records where the 2179 density pass landed', () => {
354
354
  // no shell.js, a private hardcoded palette that silently kept the WCAG-failing
355
355
  // accent when the pack moved (idea 1000600). Task 1002868 adopted them, so the
356
356
  // rule is now universal and exception-free.
357
+ // THE SKY is the one page that is not the hall's chrome, by the owner's decision
358
+ // (task 1004231, ADR 0331): modules/hall-ui/public/thinking.html is the
359
+ // prototype adopted as the design of record — a full-screen black surface with
360
+ // its own header, composer and legend — and its <style> is the pixel contract.
361
+ // It is not the fork this rule was written against: its palette is not a stale
362
+ // private copy of the hall's, it is a different surface's own, pinned by
363
+ // tests/hall_sky.mjs against the template. It still stamps the branding token
364
+ // and the pack's font links like every hall page (the server injects them).
365
+ const STANDALONE_EXEMPT = {
366
+ 'thinking.html': 'the sky (task 1004231, ADR 0331) — the design of record is its own surface, not the hall chrome',
367
+ };
368
+
357
369
  test('every hall page loads style.css and shell.js', () => {
358
370
  const missing = [];
359
371
  for (const f of pages()) {
372
+ if (STANDALONE_EXEMPT[f]) continue;
360
373
  const html = read(f);
361
374
  if (!/<link[^>]+href="[^"]*\bstyle\.css[^"]*"/.test(html)) missing.push(`${f}: no style.css link`);
362
375
  if (!/<script[^>]+src="[^"]*\bshell\.js[^"]*"/.test(html)) missing.push(`${f}: no shell.js`);
@@ -400,6 +413,15 @@ test('no page declares a private copy of a pack color var', () => {
400
413
  const LITERAL_EXEMPT = {
401
414
  'style.css': 'the token tier — this is the ONE file whose job is to hold the literals',
402
415
  'settings.css': 'the Discord button wears Discord\'s brand colour (#5865F2); a third-party mark is not a theme colour',
416
+ // The sky (task 1004231, ADR 0331) is the prototype the owner adopted as the
417
+ // design of record, verbatim: its sheet IS its token tier (a black void, one
418
+ // accent, the chart yellow, and the alpha-white ladder the canvas shares),
419
+ // and the two scripts that write inline colour do so with the template's own
420
+ // values. The atlas precedent: a page-scoped palette, pinned rather than
421
+ // re-tokened.
422
+ 'thinking.css': 'the sky\'s own sheet is its token tier (task 1004231, ADR 0331 — the design of record)',
423
+ 'sky-interaction.js': 'the sky\'s hover card writes the template\'s ring colour inline (task 1004231)',
424
+ 'sky-panel.js': 'the sky\'s panel writes the template\'s dead-bar colour inline (task 1004231)',
403
425
  };
404
426
  const COLOUR_PROPS = 'color|background|background-color|border|border-color|border-top-color|border-bottom-color|fill|stroke|outline|outline-color|box-shadow|text-shadow';
405
427
  const colourLiteralRe = () =>
@@ -465,6 +487,11 @@ test('the verdict classes and the scrim exist in the token tier, in both modes',
465
487
  // exceptions are selectors that are not keyboard stops at all.
466
488
  const OUTLINE_NONE_EXEMPT = {
467
489
  main: 'the skip link\'s programmatic target — main takes tabindex="-1" focus so the reader lands past the nav, and ringing the whole page would be noise, not information',
490
+ // The sky's composer (task 1004231, ADR 0331): the line sits inside a pill
491
+ // form, and the indication is the FORM's ring — `.composer form:focus-within`
492
+ // brightens its inset hairline — which is the template's own design. A ring
493
+ // on the input inside the pill would draw two.
494
+ '.composer input': 'the sky\'s composer line — the indication is the pill form\'s focus-within ring (thinking.css), the template\'s own',
468
495
  };
469
496
 
470
497
  // Innermost rule blocks only, so a rule inside @media is read as itself and the
@@ -287,7 +287,9 @@ test('sidebar groups read by audience, in order', () => {
287
287
  // 'deploy' (task 1003159, ADR 0293) is the SECOND archon item, beside 'government':
288
288
  // its gate is the `core.pin.move` atom, archon-floor and system:true. It sits with
289
289
  // the steering surfaces because that is what it is — the owner's third release gate.
290
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'government']);
290
+ // 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
291
+ // what each hosted project costs is an operator surface, like the rest of this group.
292
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
291
293
  });
292
294
 
293
295
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -68,12 +68,15 @@ const ORACLE = {
68
68
  // having a thought, and the page reads the VIEWER's own trail (the id comes
69
69
  // from GET /me, never from the URL), so a rank could protect nothing the
70
70
  // session does not already.
71
+ // Since task 1004231 (sky part 3, ADR 0331) that page IS the sky — the
72
+ // prototype the owner adopted, over GET /sky + GET /sky/mine, with the room
73
+ // inside it as the list drawer. Both reads are any-builder and the trail is
74
+ // the viewer's own, so sign-in is still the whole gate.
71
75
  '/builders/thinking': 'requireBuilderPage',
72
- // THE SKY (task 1004105 / BV2.R26) — the ambient view over the SAME trail the
73
- // room lists, drawn as a map. It reads the same endpoint with the same
74
- // viewer-derived id, so it sits on the same tier: a rank floor here would
75
- // protect on one page exactly what it admits on the other.
76
- '/builders/sky': 'requireBuilderPage',
76
+ // Gone: the old ambient sky (task 1004105) was folded into /thinking by task
77
+ // 1004231, and /builders/sky now 301s there (serve-internal.js). A redirect is
78
+ // not a page, so it carries no gate; GONE_PAGES keeps the row honest.
79
+ '/builders/sky': null,
77
80
  '/builders/collab': 'requireBuilderPage',
78
81
  // task 1002710: the orientation reading rooms moved DOWN to this tier from a
79
82
  // thetes+ gate. They are the onboarding documents, so gating them above xenos
@@ -100,6 +103,8 @@ const ORACLE = {
100
103
  // its write on purpose: the preview discloses the exact core version the box serves
101
104
  // and how far behind it is, which is a vulnerability window.
102
105
  '/builders/deploy': 'requireCorePinPage',
106
+ // The fleet cost page (task 1001949), on the atom its one read checks.
107
+ '/builders/fleet': 'requireFleetPage',
103
108
 
104
109
  // Metic+ via the `project.curate` atom — the exact permission GET /scouting
105
110
  // checks, so the page can never drift wider than the API it fronts. Its
@@ -301,6 +306,9 @@ const GONE_PAGES = {
301
306
  // Task 1003890 — the dev-box deprecation removed the fleet these two drove.
302
307
  '/builders/harbor': 'task 1003890 — the dev box is gone, so its Archon view is too',
303
308
  '/builders/pair': 'task 1003890 — the dev box is gone, so its pairing page is too',
309
+ // Task 1004231 — the ideator's sky replaced both the room and the old sky with
310
+ // one page at /thinking; /sky is a 301 to it, not a page.
311
+ '/builders/sky': 'task 1004231 — the sky lives at /thinking now; /sky redirects there',
304
312
  };
305
313
 
306
314
  test('a row that is null because the page left the hall serves no page', () => {