@bongos/core 1.20.46 → 1.20.48

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 (68) hide show
  1. package/.bongos-core.json +103 -68
  2. package/docs/copy-inventory.md +230 -225
  3. package/docs/copy-registry.json +371 -286
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +7 -4
  6. package/docs/page-readings.json +248 -249
  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/bulk-toolbar.js +291 -0
  11. package/modules/hall-ui/public/collab.html +1 -1
  12. package/modules/hall-ui/public/commands.html +1 -1
  13. package/modules/hall-ui/public/copy-desk.html +1 -1
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goals-page.js +29 -44
  20. package/modules/hall-ui/public/goals-render.js +33 -21
  21. package/modules/hall-ui/public/goals.html +13 -17
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/hall-render.js +3 -0
  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/primer.html +1 -1
  29. package/modules/hall-ui/public/profile.html +1 -1
  30. package/modules/hall-ui/public/project-settings.html +1 -1
  31. package/modules/hall-ui/public/ranks.html +1 -1
  32. package/modules/hall-ui/public/roadmap.html +1 -1
  33. package/modules/hall-ui/public/roster.html +1 -1
  34. package/modules/hall-ui/public/sessions.html +1 -1
  35. package/modules/hall-ui/public/settings.html +1 -1
  36. package/modules/hall-ui/public/shell.js +82 -1
  37. package/modules/hall-ui/public/studio.html +1 -1
  38. package/modules/hall-ui/public/style.css +110 -3
  39. package/modules/hall-ui/public/task.html +1 -1
  40. package/modules/hall-ui/public/thinking.html +1 -1
  41. package/modules/hall-ui/public/tweak-editor.html +1 -1
  42. package/modules/hall-ui/public/watch.html +1 -1
  43. package/modules/hall-ui/public/work-founding.states.json +39 -0
  44. package/modules/hall-ui/public/work.css +4 -66
  45. package/modules/hall-ui/public/work.html +3 -2
  46. package/modules/hall-ui/public/work.js +17 -236
  47. package/modules/hall-ui/records/founding-mode.md +31 -0
  48. package/modules/onboarding/founding-mode.js +185 -0
  49. package/modules/onboarding/port.js +7 -0
  50. package/modules/ui-design/kit/fixtures/me-founding.json +24 -0
  51. package/modules/ui-design/kit/serve.js +3 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/release-notes.json +12 -0
  55. package/src/bongos/routes/me.js +18 -1
  56. package/src/module-api.js +1 -1
  57. package/tests/bulk_toolbar.mjs +202 -0
  58. package/tests/claim_action.mjs +13 -4
  59. package/tests/founding_mode.mjs +294 -0
  60. package/tests/goal_category_ui.mjs +1 -0
  61. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  62. package/tests/goal_forest_page.mjs +1 -0
  63. package/tests/goal_invite_consent.mjs +1 -1
  64. package/tests/goal_mine_lens.mjs +1 -0
  65. package/tests/hall_board_world.mjs +1 -1
  66. package/tests/hall_mine_lens.mjs +1 -0
  67. package/tests/hall_tweak_editor.mjs +1 -1
  68. package/tests/nav_permission_atoms.mjs +1 -1
@@ -277,7 +277,9 @@ function fileUnder(base, rel) {
277
277
  // is absolutised to this origin per request.
278
278
  const AVATAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32" fill="#888"/><circle cx="32" cy="26" r="11" fill="#ddd"/><path d="M12 56c3-12 11-17 20-17s17 5 20 17" fill="#ddd"/></svg>';
279
279
  function me(origin) {
280
- const m = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'me.json'), 'utf8'));
280
+ // stub.fixtures may re-point the identity too (task 1004422: the founding-mode render) —
281
+ // the same ALIAS every other fixture honours, so a variant states file needs no stub code.
282
+ const m = JSON.parse(fs.readFileSync(path.join(FIXTURES, (ALIAS.me || 'me') + '.json'), 'utf8'));
281
283
  if (m.builder && typeof m.builder.avatar_url === 'string' && m.builder.avatar_url.startsWith('/')) m.builder.avatar_url = origin + m.builder.avatar_url;
282
284
  return m;
283
285
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.46",
3
+ "version": "1.20.48",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.46",
9
+ "version": "1.20.48",
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.20.46",
3
+ "version": "1.20.48",
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",
@@ -8389,5 +8389,17 @@
8389
8389
  "id": "1004366",
8390
8390
  "text": "Every store module version now has a readable how-to page in the hall, with a Save as PDF button and an optional link to a Claude page."
8391
8391
  }
8392
+ ],
8393
+ "1.20.47": [
8394
+ {
8395
+ "id": "1003141",
8396
+ "text": "The Goals page now has the same bulk actions as the Tasks board: claim several tasks, release your own, or copy a prompt to work them in parallel or in order. Both pages now share one toolbar, so they stay in step."
8397
+ }
8398
+ ],
8399
+ "1.20.48": [
8400
+ {
8401
+ "id": "1004422",
8402
+ "text": "While a new project is being founded, its builders hall now shows a clear \"project founding mode\" band at the top and dims the rooms that open later. A hall that isn't being founded stays exactly as it was."
8403
+ }
8392
8404
  ]
8393
8405
  }
@@ -187,7 +187,7 @@ module.exports = function buildMeRouter() {
187
187
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
188
188
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
189
189
  taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
190
- navPermissions,
190
+ navPermissions, foundingMode,
191
191
  ] = await Promise.all([
192
192
  db.getBuilderById(req.builder.id),
193
193
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -308,6 +308,16 @@ module.exports = function buildMeRouter() {
308
308
  // Same fan-out slot as the reads above, but it fails CLOSED rather than
309
309
  // open — null hides the link. See navPermissionsFor.
310
310
  navPermissionsFor(req.builder.id),
311
+ // task 1004422 (BV2.PS03): PROJECT FOUNDING MODE — is this project still being
312
+ // founded, and at which genesis stage. Same fan-out slot and posture as social
313
+ // mode: null (not founding, or unreadable) is OFF, and the hall then renders
314
+ // exactly as it does today, which is the owner's no-hijack condition.
315
+ (async () => {
316
+ try {
317
+ if (!onboarding || !onboarding.foundingModeFor) return null;
318
+ return await onboarding.foundingModeFor();
319
+ } catch (_) { return null; }
320
+ })(),
311
321
  ]);
312
322
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
313
323
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -378,6 +388,13 @@ module.exports = function buildMeRouter() {
378
388
  // when lifecycle is disabled or the read failed, and the shell treats null
379
389
  // as OFF — a mode that cannot be read is not on.
380
390
  social: socialMode,
391
+ // task 1004422 (BV2.PS03, goal 1000121): PROJECT FOUNDING MODE. Genesis is a
392
+ // mode the EXISTING hall wears (the blend rule, docs/design/project-startup-
393
+ // direction.md), so it rides /me exactly like social: the shell stamps
394
+ // html[data-founding], draws the founding band and dims the rooms still asleep.
395
+ // Shape: { active, stage, stage_number, stage_count, day, asleep: {navId: stage} },
396
+ // or null — and null is OFF, the hall as today.
397
+ founding_mode: foundingMode,
381
398
  // task 1003744: { '<atom>': boolean } for each permission key a hall nav
382
399
  // item may gate on (NAV_PERMISSION_ATOMS above), so an item can declare the
383
400
  // SAME atom its page is gated on instead of borrowing the nearest rank
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.46'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.48'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,202 @@
1
+ // tests/bulk_toolbar.mjs — the hall's shared bulk toolbar (task 1003141).
2
+ //
3
+ // One toolbar, two boards: /work and the goal board build the SAME one from
4
+ // modules/hall-ui/public/bulk-toolbar.js. This runs the real module (it is
5
+ // node-require-safe) against a stub page, so what it proves is behaviour — a
6
+ // release selection really enables Bulk release, a cycle really disables the
7
+ // sequential prompt — rather than that two files agree on a spelling.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(import.meta.dirname, '..');
16
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
17
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
18
+ const { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar } = require(path.join(PUB, 'bulk-toolbar.js'));
19
+
20
+ const loc = { hostname: 'builders.example.com', origin: 'https://builders.example.com' };
21
+
22
+ test('topoOrder puts a dependency first and ignores edges outside the selection', () => {
23
+ const edges = new Map([[2, [1]], [1, [99]], [3, []]]);
24
+ const order = topoOrder([2, 1, 3], edges);
25
+ assert.ok(order.indexOf(1) < order.indexOf(2), 'a dependency precedes its dependent');
26
+ assert.equal(order.length, 3);
27
+ });
28
+
29
+ test('topoOrder answers null for a cycle', () => {
30
+ assert.equal(topoOrder([1, 2], new Map([[1, [2]], [2, [1]]])), null);
31
+ });
32
+
33
+ test('the prompts name every task and the hall link', () => {
34
+ const tasks = [{ id: 5, title: 'Five' }, { id: 6, title: 'Six' }];
35
+ const par = buildBulkParallelPrompt(tasks, loc);
36
+ assert.match(par, /2 Bongos tasks IN PARALLEL/);
37
+ assert.match(par, /Session 2 — task 6 \(Six\) — https:\/\/builders\.example\.com#\/task\/6/);
38
+ const seq = buildBulkSequentialPrompt(tasks, { hostname: 'example.com', origin: 'https://example.com' });
39
+ assert.match(seq, /IN ORDER/);
40
+ assert.match(seq, /1\. Task 5 \(Five\) — https:\/\/example\.com\/builders#\/task\/5/);
41
+ });
42
+
43
+ // --- the factory, against a stub page --------------------------------------
44
+
45
+ function stubPage(present) {
46
+ const els = {};
47
+ for (const id of present) els[id] = { hidden: true, disabled: false, textContent: '', title: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
48
+ globalThis.document = { getElementById: (id) => els[id] || null };
49
+ globalThis.window = { confirm: () => true };
50
+ globalThis.location = loc;
51
+ return els;
52
+ }
53
+
54
+ function build(els, over = {}) {
55
+ const calls = { claimMany: [], releaseMany: [], copied: [], toasts: [] };
56
+ const selection = { claim: new Set(), release: new Map() };
57
+ const tasks = new Map([[1, { id: 1, title: 'One' }], [2, { id: 2, title: 'Two' }]]);
58
+ const bar = makeBulkToolbar({
59
+ ids: { bar: 'bar', count: 'count', claim: 'claim', release: 'release', parallel: 'parallel', sequential: 'sequential', clear: 'clear' },
60
+ selection,
61
+ findTask: (id) => tasks.get(id),
62
+ claimAction: {
63
+ validateMany: async () => ({ ok: true, parallelSafe: true, byId: new Map() }),
64
+ claimMany: async (ids) => { calls.claimMany.push(ids); return { ok: true }; },
65
+ releaseMany: async (e) => { calls.releaseMany.push(e); return { ok: true }; },
66
+ },
67
+ getJSON: async () => ({ task: { dependencies: [] } }),
68
+ API: '/api/bongos',
69
+ copyText: async (t) => { calls.copied.push(t); return true; },
70
+ toast: (m) => calls.toasts.push(m),
71
+ curLabel: () => 'credits',
72
+ describeReason: (r) => r,
73
+ onClear: () => { calls.cleared = true; },
74
+ ...over,
75
+ });
76
+ return { bar, selection, calls };
77
+ }
78
+ const settle = () => new Promise((r) => setTimeout(r, 1000));
79
+
80
+ test('nothing selected hides the bar', () => {
81
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
82
+ const { bar } = build(els);
83
+ els.bar.hidden = false;
84
+ bar.render();
85
+ assert.equal(els.bar.hidden, true);
86
+ });
87
+
88
+ test('a release selection alone enables Bulk release and leaves claim off', () => {
89
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
90
+ const { bar, selection } = build(els);
91
+ selection.release.set('77', '9');
92
+ bar.render();
93
+ assert.equal(els.bar.hidden, false);
94
+ assert.equal(els.count.textContent, '1 to release');
95
+ assert.equal(els.release.disabled, false);
96
+ assert.equal(els.claim.disabled, true);
97
+ });
98
+
99
+ test('two ready tasks enable both prompts once the checks come back, and the prompts copy', async () => {
100
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
101
+ const { bar, selection, calls } = build(els);
102
+ selection.claim.add('1'); selection.claim.add('2');
103
+ bar.render();
104
+ assert.equal(els.parallel.disabled, true, 'disabled while the dry run is pending');
105
+ await settle();
106
+ assert.equal(els.parallel.disabled, false);
107
+ assert.equal(els.sequential.disabled, false);
108
+ assert.equal(els.claim.textContent, 'Bulk claim (2)');
109
+ bar.wire();
110
+ await els.parallel.listeners.click();
111
+ await els.sequential.listeners.click();
112
+ assert.equal(calls.copied.length, 2);
113
+ assert.match(calls.copied[0], /IN PARALLEL/);
114
+ assert.match(calls.copied[1], /IN ORDER/);
115
+ });
116
+
117
+ test('a dependency cycle disables the sequential prompt with the reason', async () => {
118
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
119
+ const edges = { 1: [2], 2: [1] };
120
+ const { bar, selection } = build(els, { getJSON: async (p) => ({ task: { dependencies: edges[Number(p.split('/').pop())].map((id) => ({ id })) } }) });
121
+ selection.claim.add('1'); selection.claim.add('2');
122
+ bar.render();
123
+ await settle();
124
+ assert.equal(els.sequential.disabled, true);
125
+ assert.match(els.sequential.title, /cycle/);
126
+ });
127
+
128
+ test('a page without the optional buttons simply has no such feature', async () => {
129
+ const els = stubPage(['bar', 'count', 'claim', 'clear']);
130
+ const { bar, selection } = build(els);
131
+ selection.claim.add('1'); selection.claim.add('2');
132
+ assert.doesNotThrow(() => bar.render());
133
+ assert.doesNotThrow(() => bar.wire());
134
+ assert.equal(els.claim.textContent, 'Bulk claim (2)');
135
+ });
136
+
137
+ test('Bulk claim and Bulk release go through the shared action and empty their selection', async () => {
138
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
139
+ const { bar, selection, calls } = build(els);
140
+ selection.claim.add('1'); selection.release.set('77', '9');
141
+ bar.wire();
142
+ await els.claim.listeners.click();
143
+ assert.deepEqual(calls.claimMany, [[1]]);
144
+ assert.equal(selection.claim.size, 0);
145
+ await els.release.listeners.click();
146
+ assert.deepEqual(calls.releaseMany, [[['77', '9']]]);
147
+ assert.equal(selection.release.size, 0);
148
+ });
149
+
150
+ test('Clear empties both selections and lets the page repaint its cards', () => {
151
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
152
+ const { bar, selection, calls } = build(els);
153
+ selection.claim.add('1'); selection.release.set('77', '9');
154
+ bar.clearSelection();
155
+ assert.equal(selection.claim.size + selection.release.size, 0);
156
+ assert.equal(calls.cleared, true);
157
+ assert.equal(els.bar.hidden, true);
158
+ });
159
+
160
+ // --- the two boards use it, and neither keeps a private copy -----------------
161
+
162
+ test('both boards build the shared toolbar and carry all five buttons', () => {
163
+ const work = read('work.html');
164
+ const goals = read('goals.html');
165
+ for (const k of ['claim', 'release', 'parallel', 'sequential', 'clear']) {
166
+ assert.match(work, new RegExp(`id="bulk-${k}-btn"`), `work.html has bulk-${k}-btn`);
167
+ assert.match(goals, new RegExp(`id="goal-bulk-${k}-btn"`), `goals.html has goal-bulk-${k}-btn`);
168
+ }
169
+ assert.match(read('work.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
170
+ assert.match(read('goals-render.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
171
+ for (const [page, scripts] of [['work.html', ['bulk-toolbar.js', 'work.js']], ['goals.html', ['bulk-toolbar.js', 'goals-render.js']]]) {
172
+ const html = read(page);
173
+ const at = (n) => html.indexOf(`src="/builders/${n}`);
174
+ assert.ok(at(scripts[0]) > -1 && at(scripts[0]) < at(scripts[1]), `${page}: bulk-toolbar.js loads before ${scripts[1]}`);
175
+ }
176
+ });
177
+
178
+ test('no board keeps a private copy of the toolbar logic', () => {
179
+ for (const f of ['work.js', 'goals-page.js', 'goals-render.js']) {
180
+ const src = read(f);
181
+ assert.doesNotMatch(src, /function topoOrder|function buildBulk(Parallel|Sequential)Prompt|function scheduleValidate|function doBulk(Claim|Release)|function doGoalBulkClaim/, `${f} re-grew toolbar logic`);
182
+ }
183
+ });
184
+
185
+ test('the goal board offers a release checkbox on the viewer\'s own cards, and it styles', () => {
186
+ const render = read('goals-render.js');
187
+ assert.match(render, /class="wip__select" data-claim-id=/, 'a mine-active card carries the release checkbox');
188
+ assert.match(read('goals-page.js'), /closest\('\.task__select, \.wip__select'\)/, 'the card list listens for it');
189
+ assert.match(read('style.css'), /\n\.wip__select \{/, '.wip__select is in the shared sheet the goal board loads');
190
+ });
191
+
192
+ test('goals.html no longer borrows work.css, and work.css no longer holds what it lent', () => {
193
+ assert.doesNotMatch(read('goals.html'), /work\.css/, 'the goal board is off the board-only sheet');
194
+ const work = read('work.css');
195
+ for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
196
+ assert.ok(!new RegExp(`^${sel.replace('.', '\\.')}(\\s|:|,|\\{)`, 'm').test(work), `work.css must not restate ${sel}`);
197
+ }
198
+ const style = read('style.css');
199
+ for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
200
+ assert.ok(new RegExp(`^${sel.replace('.', '\\.')} \\{`, 'm').test(style), `style.css carries ${sel}`);
201
+ }
202
+ });
@@ -264,8 +264,8 @@ await test('both boards CONSTRUCT the shared action, not just its error text', (
264
264
 
265
265
  await test('every claim/release entry point routes through it', () => {
266
266
  const sites = {
267
- 'work.js': ['doClaim', 'doRelease', 'doBulkClaim', 'doBulkRelease'],
268
- 'goals-page.js': ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim'],
267
+ 'work.js': ['doClaim', 'doRelease'],
268
+ 'goals-page.js': ['doGoalClaim', 'doGoalRelease'],
269
269
  };
270
270
  for (const [f, fns] of Object.entries(sites)) {
271
271
  const src = read('modules', 'hall-ui', 'public', f);
@@ -278,6 +278,15 @@ await test('every claim/release entry point routes through it', () => {
278
278
  `${f}: ${fn} still maps its own refusal`);
279
279
  }
280
280
  }
281
+ // Both boards' BULK claim/release live in the shared toolbar now (task 1003141),
282
+ // built over the same action — so the same rule is checked there, once.
283
+ const bulk = read('modules', 'hall-ui', 'public', 'bulk-toolbar.js');
284
+ for (const fn of ['doBulkClaim', 'doBulkRelease']) {
285
+ const m = bulk.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
286
+ assert.ok(m, `bulk-toolbar.js: ${fn} not found`);
287
+ assert.match(m[0], /claimAction\.(claimMany|releaseMany)\(/, `bulk-toolbar.js: ${fn} does not go through the shared action`);
288
+ assert.doesNotMatch(m[0], /friendlyClaimError\(/, `bulk-toolbar.js: ${fn} maps its own refusal`);
289
+ }
281
290
  });
282
291
 
283
292
  await test('no call site reloads on top of the action\'s own refresh', async () => {
@@ -287,7 +296,7 @@ await test('no call site reloads on top of the action\'s own refresh', async ()
287
296
  // twice. work.js's doBulkClaim is the correct shape: redraw the toolbar,
288
297
  // never re-fetch. A finally that awaits a loader is the tell.
289
298
  const src = read('modules', 'hall-ui', 'public', 'goals-page.js');
290
- for (const fn of ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim']) {
299
+ for (const fn of ['doGoalClaim', 'doGoalRelease']) {
291
300
  const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
292
301
  assert.ok(m, `${fn} not found`);
293
302
  assert.doesNotMatch(m[0], /finally \{[\s\S]*?await loadGoalTasks\(/,
@@ -393,7 +402,7 @@ await test('the cache-bust moved on every page whose script changed', () => {
393
402
  ]) {
394
403
  const html = read('modules', 'hall-ui', 'public', page);
395
404
  for (const s of scripts) {
396
- assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-26-claimaction1'), `${page}: ${s} not busted`);
405
+ assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-30-bulktoolbar'), `${page}: ${s} not busted`);
397
406
  }
398
407
  }
399
408
  });
@@ -0,0 +1,294 @@
1
+ // tests/founding_mode.mjs — PROJECT FOUNDING MODE is a mode the existing hall wears,
2
+ // never a second hall (task 1004422 / BV2.PS03, goal 1000121; the blend rule in
3
+ // docs/design/project-startup-direction.md).
4
+ //
5
+ // The owner's no-hijack condition is the headline: a hall that is NOT founding must be
6
+ // the hall it was. So the client half is RUN, not grepped. The real applyFounding is
7
+ // sliced out of shell.js and driven over a small fake DOM, fed by the REAL server
8
+ // shaper, so the path from the stored stage to the dimmed nav item is exercised
9
+ // whole. Then OFF is proven to leave, and to restore, the DOM exactly.
10
+ import { strict as assert } from 'node:assert';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8').replace(/\r\n/g, '\n');
20
+ const fm = require(path.join(ROOT, 'modules', 'onboarding', 'founding-mode.js'));
21
+
22
+ const SHELL = read('modules', 'hall-ui', 'public', 'shell.js');
23
+ const HALL_RENDER = read('modules', 'hall-ui', 'public', 'hall-render.js');
24
+ const ME = read('src', 'bongos', 'routes', 'me.js');
25
+ const PORT = read('modules', 'onboarding', 'port.js');
26
+
27
+ // ── the server shaper (pure) ────────────────────────────────────────────────
28
+
29
+ test('not founding in any form is null (OFF)', () => {
30
+ assert.equal(fm.shapeFoundingMode(null), null);
31
+ assert.equal(fm.shapeFoundingMode({ active: null }), null);
32
+ assert.equal(fm.shapeFoundingMode({ active: 'false', stage: 'scope' }), null);
33
+ assert.equal(fm.shapeFoundingMode({ active: 'maybe', stage: 'scope' }), null);
34
+ });
35
+
36
+ test('an unknown stage fails CLOSED — OFF, never a hall stuck half-asleep', () => {
37
+ assert.equal(fm.shapeFoundingMode({ active: 'true', stage: 'launch' }), null);
38
+ });
39
+
40
+ test('active with no stage yet is the decide pre-stage, and every room sleeps', () => {
41
+ const f = fm.shapeFoundingMode({ active: 'true' });
42
+ assert.equal(f.stage, 'decide');
43
+ assert.equal(f.stage_number, 0);
44
+ assert.equal(f.stage_count, 5);
45
+ for (const id of ['roadmap', 'goals', 'goal-map', 'work', 'board-room', 'economy', 'ranks', 'collab', 'roster']) {
46
+ assert.ok(id in f.asleep, `${id} sleeps while deciding`);
47
+ }
48
+ });
49
+
50
+ test('a room wakes once the stage that wakes it is behind', () => {
51
+ const f = fm.shapeFoundingMode({ active: 'true', stage: 'charter' });
52
+ for (const id of ['roadmap', 'goals', 'goal-map', 'work']) assert.ok(!(id in f.asleep), `${id} woke`);
53
+ assert.equal(f.asleep['board-room'], 'charter');
54
+ assert.equal(f.asleep.economy, 'legalize');
55
+ assert.equal(f.asleep.ranks, 'legislation');
56
+ assert.equal(f.asleep.collab, 'alive', 'community wakes only when the planet is alive');
57
+ });
58
+
59
+ test('the day counts from the founding day; an unreadable timestamp drops it', () => {
60
+ const now = new Date('2026-10-09T12:00:00Z');
61
+ assert.equal(fm.dayOf('2026-10-01T09:00:00Z', now), 9);
62
+ assert.equal(fm.dayOf('not a date', now), null);
63
+ assert.equal(fm.dayOf(null, now), null);
64
+ });
65
+
66
+ test('foundingModeFor fails closed when the settings read throws', async () => {
67
+ const got = await fm.foundingModeFor({ getSetting: async () => { throw new Error('db down'); } });
68
+ assert.equal(got, null);
69
+ });
70
+
71
+ test('setFoundingMode refuses an unknown stage and stamps started_at once', async () => {
72
+ const store = new Map();
73
+ const deps = {
74
+ getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
75
+ setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
76
+ now: new Date('2026-10-01T00:00:00Z'),
77
+ };
78
+ await assert.rejects(fm.setFoundingMode({ stage: 'launch' }, 1, deps), /unknown founding stage/);
79
+ const first = await fm.setFoundingMode({ active: true, stage: 'decide' }, 1, deps);
80
+ assert.equal(first.stage, 'decide');
81
+ const stamp = store.get(fm.KEYS.STARTED_AT);
82
+ deps.now = new Date('2026-10-05T00:00:00Z');
83
+ await fm.setFoundingMode({ active: true, stage: 'scope' }, 1, deps);
84
+ assert.equal(store.get(fm.KEYS.STARTED_AT), stamp, 'started_at is not re-stamped');
85
+ assert.equal(await fm.setFoundingMode({ active: false }, 1, deps), null, 'switching off reads back as OFF');
86
+ });
87
+
88
+ test('every room the server can put to sleep is a real NAV item', () => {
89
+ // The map is keyed by NAV id so the client never re-derives it; a renamed NAV id
90
+ // would silently leave a room awake. Hold the two together.
91
+ const nav = SHELL.slice(SHELL.indexOf('var NAV = ['), SHELL.indexOf('var NAV_COLLAPSE_KEY'));
92
+ const ids = new Set([...nav.matchAll(/id: '([a-z-]+)'/g)].map((m) => m[1]));
93
+ const all = [...Object.values(fm.WAKES).flat(), ...fm.WAKES_WHEN_ALIVE];
94
+ for (const id of all) assert.ok(ids.has(id), `NAV has no item '${id}'`);
95
+ });
96
+
97
+ // ── the client, RUN over a fake DOM ─────────────────────────────────────────
98
+
99
+ class El {
100
+ constructor(tag) {
101
+ this.tag = tag; this.id = ''; this.className = ''; this.dataset = {}; this.attrs = {};
102
+ this.children = []; this.parent = null; this.textContent = ''; this.innerHTML = '';
103
+ const self = this;
104
+ this.classList = {
105
+ add(c) { const s = new Set(self.className.split(' ').filter(Boolean)); s.add(c); self.className = [...s].join(' '); },
106
+ remove(c) { self.className = self.className.split(' ').filter((x) => x && x !== c).join(' '); },
107
+ contains(c) { return self.className.split(' ').includes(c); },
108
+ };
109
+ }
110
+ appendChild(c) { c.parent = this; this.children.push(c); return c; }
111
+ insertBefore(c, ref) { c.parent = this; const i = this.children.indexOf(ref); this.children.splice(i < 0 ? 0 : i, 0, c); return c; }
112
+ get parentNode() { return this.parent; }
113
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; return c; }
114
+ setAttribute(k, v) { this.attrs[k] = v; }
115
+ querySelector(sel) { return sel === '[data-wakes-tag]' ? (this.children.find((c) => 'data-wakes-tag' in c.attrs) || null) : null; }
116
+ get firstChild() { return this.children[0] || null; }
117
+ }
118
+
119
+ function makeHall() {
120
+ const navIds = ['home', 'roadmap', 'goals', 'goal-map', 'work', 'board-room', 'economy', 'ranks', 'collab', 'roster', 'profile'];
121
+ const nav = navIds.map((id) => { const a = new El('a'); a.id = 'nav-' + id; a.className = 'nav-item'; return a; });
122
+ const appBody = new El('div');
123
+ const topbar = new El('header'); topbar.id = 'app-topbar';
124
+ appBody.appendChild(topbar);
125
+ const root = { dataset: {} };
126
+ const document = {
127
+ documentElement: root,
128
+ createElement: (t) => new El(t),
129
+ querySelector: (sel) => (sel === '.app-body' ? appBody : null),
130
+ querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep' ? nav.filter((a) => a.classList.contains('nav-item--asleep')) : []),
131
+ getElementById: (id) => nav.find((a) => a.id === id) || appBody.children.find((c) => c.id === id) || null,
132
+ };
133
+ return { document, nav, appBody, root };
134
+ }
135
+
136
+ // The REAL escaper and the real wake map are sliced out of shell.js with the function,
137
+ // so the band's escaping is the shell's own, not a stand-in that agrees with the test.
138
+ function sliceFn(name) {
139
+ const start = SHELL.indexOf(' function ' + name + '(');
140
+ const end = SHELL.indexOf('\n }\n', start);
141
+ assert.ok(start > -1 && end > start, name + ' was located in shell.js');
142
+ return SHELL.slice(start, end + 4);
143
+ }
144
+ function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }) {
145
+ const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
146
+ const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
147
+ assert.ok(wakeStart > -1, 'WAKE_TEXT was located in shell.js');
148
+ const src = [SHELL.slice(wakeStart, wakeEnd), sliceFn('esc'), sliceFn('applyFounding')].join('\n');
149
+ return new Function('document', 'brand', 'access', `${src}\nreturn applyFounding;`)(document, brand, access);
150
+ }
151
+
152
+ const snapshot = (h) => JSON.stringify({
153
+ root: h.root.dataset,
154
+ body: h.appBody.children.map((c) => c.id),
155
+ nav: h.nav.map((a) => ({ id: a.id, cls: a.className, data: a.dataset, kids: a.children.length })),
156
+ });
157
+
158
+ test('THE NO-HIJACK CONDITION: a hall that is not founding is untouched', () => {
159
+ const h = makeHall();
160
+ const before = snapshot(h);
161
+ const access = { founding: null };
162
+ loadApplyFounding(h.document, access)();
163
+ assert.equal(snapshot(h), before, 'null founding changes nothing at all');
164
+ });
165
+
166
+ test('not founding touches NO node — the document is never even queried', () => {
167
+ // Stronger than "ends up the same": a page that never wore the mode is not read.
168
+ const h = makeHall();
169
+ const touched = [];
170
+ const spy = new Proxy(h.document, {
171
+ get(t, k) { if (k !== 'documentElement') touched.push(String(k)); return t[k]; },
172
+ });
173
+ loadApplyFounding(spy, { founding: null })();
174
+ loadApplyFounding(spy, { founding: { active: false } })();
175
+ assert.deepEqual(touched, [], 'no DOM method was called');
176
+ });
177
+
178
+ test('founding ON: the band above the topbar, and the rooms the server named asleep', () => {
179
+ const h = makeHall();
180
+ const now = new Date('2026-10-09T12:00:00Z');
181
+ const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'strategize', startedAt: '2026-10-01T09:00:00Z' }, now) };
182
+ loadApplyFounding(h.document, access)();
183
+ assert.equal(h.root.dataset.founding, 'on');
184
+ const band = h.appBody.children[0];
185
+ assert.equal(band.id, 'founding-band', 'the band is the first child of .app-body, above the topbar');
186
+ assert.equal(h.appBody.children[1].id, 'app-topbar', 'the topbar itself is kept, not replaced');
187
+ assert.match(band.innerHTML, /Project founding mode/);
188
+ assert.match(band.innerHTML, /Kestrel is still being founded/);
189
+ assert.match(band.innerHTML, /stage 2 of 5 · strategize · day 9/);
190
+ const byId = (id) => h.nav.find((a) => a.id === 'nav-' + id);
191
+ assert.ok(!byId('goals').classList.contains('nav-item--asleep'), 'scope is behind: goals is awake');
192
+ assert.ok(byId('work').classList.contains('nav-item--asleep'), 'strategize is current: tasks still sleeps');
193
+ assert.equal(byId('work').children[0].textContent, 'opens after strategy', 'a sentence, never a raw slug');
194
+ assert.equal(byId('collab').children[0].textContent, 'opens when alive');
195
+ assert.ok(!byId('home').classList.contains('nav-item--asleep'), 'home is never put to sleep');
196
+ });
197
+
198
+ test('founding OFF after ON restores the hall exactly — OFF removes, it does not hide', () => {
199
+ const h = makeHall();
200
+ const before = snapshot(h);
201
+ const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'decide' }) };
202
+ const apply = loadApplyFounding(h.document, access);
203
+ apply();
204
+ assert.notEqual(snapshot(h), before);
205
+ access.founding = null;
206
+ apply();
207
+ assert.equal(snapshot(h), before, 'band, asleep classes, wake tags and the root attribute are all gone');
208
+ });
209
+
210
+ test('the band escapes what it prints — a project name cannot inject markup', () => {
211
+ const h = makeHall();
212
+ const access = { founding: Object.assign(fm.shapeFoundingMode({ active: 'true', stage: 'scope' }), { waiting_on: ['<img src=x onerror=1>'] }) };
213
+ loadApplyFounding(h.document, access, { identity: { productName: '<script>alert(1)</script>' } })();
214
+ const html = h.appBody.children[0].innerHTML;
215
+ assert.ok(!html.includes('<script>'), 'the name is escaped');
216
+ assert.ok(!html.includes('<img'), 'the waiting-on names are escaped');
217
+ assert.match(html, /&lt;script&gt;/);
218
+ });
219
+
220
+ test('who the stage close is waiting on shows in the band when the server sends it', async () => {
221
+ const f = await fm.foundingModeFor({
222
+ getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'charter' } }[k] || null),
223
+ waitingOnFor: async (stage) => (stage === 'charter' ? ['Avery', 'Jordan'] : []),
224
+ });
225
+ assert.deepEqual(f.waiting_on, ['Avery', 'Jordan']);
226
+ const h = makeHall();
227
+ loadApplyFounding(h.document, { founding: f })();
228
+ assert.match(h.appBody.children[0].innerHTML, /waiting on Avery, Jordan/);
229
+ });
230
+
231
+ test('waiting-on reads the government port\'s genesis-stage status, sitting stages only', async () => {
232
+ const gov = {
233
+ genesisStageStatus: async () => ({ stages: [
234
+ { key: 'scope', state: 'passed', waiting_on: [] },
235
+ { key: 'charter', state: 'sitting', waiting_on: [{ display_name: 'Avery' }, { github_login: 'jordan' }] },
236
+ { key: 'legalize', state: 'not_put', waiting_on: [] },
237
+ ] }),
238
+ };
239
+ const read = (stage) => fm.foundingModeFor({
240
+ getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: stage } }[k] || null),
241
+ government: gov,
242
+ });
243
+ assert.deepEqual((await read('charter')).waiting_on, ['Avery', 'jordan']);
244
+ assert.equal((await read('scope')).waiting_on, null, 'a passed stage waits on nobody');
245
+ assert.equal(fm.waitingOnFromGovernment({}), null, 'a port without the read offers none');
246
+ assert.equal((await fm.foundingModeFor({
247
+ getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' } }[k] || null), government: null,
248
+ })).waiting_on, null, 'no government module: the mode still reads, without names');
249
+ });
250
+
251
+ test('a failing waiting-on source never turns the mode off', async () => {
252
+ const f = await fm.foundingModeFor({
253
+ getSetting: async (k) => ({ [fm.KEYS.ACTIVE]: { value: 'true' }, [fm.KEYS.STAGE]: { value: 'scope' } }[k] || null),
254
+ waitingOnFor: async () => { throw new Error('government down'); },
255
+ });
256
+ assert.equal(f.active, true);
257
+ assert.equal(f.waiting_on, null);
258
+ });
259
+
260
+ test('re-applying ON does not stack a second band or duplicate wake tags', () => {
261
+ const h = makeHall();
262
+ const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'decide' }) };
263
+ const apply = loadApplyFounding(h.document, access);
264
+ apply(); apply();
265
+ assert.equal(h.appBody.children.filter((c) => c.id === 'founding-band').length, 1);
266
+ assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 1);
267
+ });
268
+
269
+ // ── the wiring ──────────────────────────────────────────────────────────────
270
+
271
+ test('only an explicit active:true turns the mode on', () => {
272
+ const fn = SHELL.slice(SHELL.indexOf(' function applyFounding()'));
273
+ assert.match(fn.slice(0, 400), /f && f\.active === true/);
274
+ });
275
+
276
+ test('/me carries founding_mode from the onboarding port, fail-open to null', () => {
277
+ assert.match(ME, /founding_mode: foundingMode/);
278
+ assert.match(ME, /onboarding\.foundingModeFor\(\)/);
279
+ const at = ME.indexOf('onboarding.foundingModeFor()');
280
+ assert.match(ME.slice(at, at + 200), /catch \(_\) \{ return null; \}/);
281
+ assert.match(PORT, /foundingModeFor: \(\.\.\.a\) => foundingMode\.foundingModeFor\(\.\.\.a\)/);
282
+ });
283
+
284
+ test('BOTH applyAccess callers pass it, or the index would never show the band', () => {
285
+ assert.match(SHELL, /founding: me\.founding_mode \|\| null/);
286
+ assert.match(HALL_RENDER, /founding: \(ctx\.lastMeState && ctx\.lastMeState\.founding_mode\) \|\| null/);
287
+ });
288
+
289
+ test('founding mode adds no page, no route and no nav item — it is a mode', () => {
290
+ const nav = SHELL.slice(SHELL.indexOf('var NAV = ['), SHELL.indexOf('var NAV_COLLAPSE_KEY'));
291
+ assert.ok(!/founding|genesis/i.test(nav), 'the NAV model must not grow a founding entry');
292
+ const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
293
+ assert.ok(!hall.some((f) => /^founding\./.test(f)), 'no founding page file');
294
+ });