@bongos/core 1.20.47 → 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 (54) hide show
  1. package/.bongos-core.json +81 -56
  2. package/docs/copy-inventory.md +82 -80
  3. package/docs/copy-registry.json +109 -91
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-inventory.json +5 -3
  6. package/docs/page-readings.json +242 -243
  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/commands.html +1 -1
  12. package/modules/hall-ui/public/copy-desk.html +1 -1
  13. package/modules/hall-ui/public/deploy.html +1 -1
  14. package/modules/hall-ui/public/diagrams.html +1 -1
  15. package/modules/hall-ui/public/drachmae.html +1 -1
  16. package/modules/hall-ui/public/fleet.html +1 -1
  17. package/modules/hall-ui/public/gate.html +1 -1
  18. package/modules/hall-ui/public/goals.html +1 -1
  19. package/modules/hall-ui/public/government.html +1 -1
  20. package/modules/hall-ui/public/hall-render.js +3 -0
  21. package/modules/hall-ui/public/idea.html +1 -1
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +1 -1
  24. package/modules/hall-ui/public/modules.html +1 -1
  25. package/modules/hall-ui/public/primer.html +1 -1
  26. package/modules/hall-ui/public/profile.html +1 -1
  27. package/modules/hall-ui/public/project-settings.html +1 -1
  28. package/modules/hall-ui/public/ranks.html +1 -1
  29. package/modules/hall-ui/public/roadmap.html +1 -1
  30. package/modules/hall-ui/public/roster.html +1 -1
  31. package/modules/hall-ui/public/sessions.html +1 -1
  32. package/modules/hall-ui/public/settings.html +1 -1
  33. package/modules/hall-ui/public/shell.js +82 -1
  34. package/modules/hall-ui/public/studio.html +1 -1
  35. package/modules/hall-ui/public/style.css +36 -0
  36. package/modules/hall-ui/public/task.html +1 -1
  37. package/modules/hall-ui/public/thinking.html +1 -1
  38. package/modules/hall-ui/public/tweak-editor.html +1 -1
  39. package/modules/hall-ui/public/watch.html +1 -1
  40. package/modules/hall-ui/public/work-founding.states.json +39 -0
  41. package/modules/hall-ui/public/work.html +1 -1
  42. package/modules/hall-ui/records/founding-mode.md +31 -0
  43. package/modules/onboarding/founding-mode.js +185 -0
  44. package/modules/onboarding/port.js +7 -0
  45. package/modules/ui-design/kit/fixtures/me-founding.json +24 -0
  46. package/modules/ui-design/kit/serve.js +3 -1
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +6 -0
  50. package/src/bongos/routes/me.js +18 -1
  51. package/src/module-api.js +1 -1
  52. package/tests/founding_mode.mjs +294 -0
  53. package/tests/hall_tweak_editor.mjs +1 -1
  54. package/tests/nav_permission_atoms.mjs +1 -1
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-mode.js — PROJECT FOUNDING MODE: is this project still
4
+ // being founded, and which stage of genesis is it on? (task 1004422 / BV2.PS03, goal
5
+ // 1000121; design of record docs/design/project-startup-direction.md.)
6
+ //
7
+ // THE BLEND RULE THIS FILE SERVES (owner, 2026-09-30). Genesis is a MODE the existing
8
+ // builders hall wears, never a second hall. So this file answers ONE question for the
9
+ // hall shell — founding or not, and at which stage — and the shell stamps the answer on
10
+ // the document the way it stamps social mode (`html[data-social]`). Nothing here renders
11
+ // anything, and a project that is not founding gets `null`, which the shell reads as OFF:
12
+ // the hall then renders exactly as it does today.
13
+ //
14
+ // WHY THE ONBOARDING MODULE. Founding is the FOUNDER-side counterpart of the joiner
15
+ // state machine this module already owns (onboarding-state.js graduates a newcomer; this
16
+ // graduates a project). It lives in project_settings (migration core_231) through the
17
+ // doorway's one accessor, like mingle.enabled, rather than a table of its own — one row
18
+ // per project is the whole state, and "do not fork a second settings store" is that
19
+ // accessor's own rule.
20
+ //
21
+ // FAIL POSTURE: CLOSED, i.e. OFF. An unreadable, absent, or malformed state is NOT
22
+ // founding. The failure this guards against is the owner's no-hijack condition: a hall
23
+ // wrongly stuck in founding mode would dim rooms that are really open. A hall wrongly
24
+ // NOT in founding mode only loses a banner. So every doubtful read answers null.
25
+
26
+ // The genesis stages, in order. 'decide' is the pre-stage the founder plans in; the
27
+ // five after it are the stages proper (spec C4). The order IS the data: a room wakes
28
+ // when the stage that wakes it has been left behind.
29
+ const STAGES = Object.freeze(['decide', 'scope', 'strategize', 'charter', 'legalize', 'legislation']);
30
+
31
+ // Which hall rooms each finished stage wakes (the direction doc's "How rooms wake"
32
+ // table). Keyed by the shell's NAV item ids, so the client applies it without a second
33
+ // mapping of its own — a surface that re-derived this could disagree with the server.
34
+ // 'community' wakes only when the planet is ALIVE, which is when founding mode switches
35
+ // off entirely, so during founding it is always asleep.
36
+ const WAKES = Object.freeze({
37
+ scope: Object.freeze(['roadmap', 'goals', 'goal-map']),
38
+ strategize: Object.freeze(['work']),
39
+ charter: Object.freeze(['board-room']),
40
+ legalize: Object.freeze(['economy']),
41
+ legislation: Object.freeze(['ranks']),
42
+ });
43
+ const WAKES_WHEN_ALIVE = Object.freeze(['collab', 'roster']);
44
+
45
+ // The project_settings keys. Namespaced 'founding.' like 'mingle.' and 'attention.'.
46
+ const KEYS = Object.freeze({
47
+ ACTIVE: 'founding.active',
48
+ STAGE: 'founding.stage',
49
+ STARTED_AT: 'founding.started_at',
50
+ });
51
+
52
+ // The only 'on' spelling accepted, mirroring mingle's consent-gate parse: an explicit,
53
+ // recognised yes. Anything else is off.
54
+ function parseActive(raw) {
55
+ if (raw == null) return false;
56
+ return ['true', '1', 'on', 'yes'].includes(String(raw).trim().toLowerCase());
57
+ }
58
+
59
+ // asleepRooms(stage) — PURE. For a known stage, { navId: wakingStage } for every room
60
+ // still asleep. A room is awake once its waking stage is BEHIND the current one.
61
+ function asleepRooms(stage) {
62
+ const at = STAGES.indexOf(stage);
63
+ if (at < 0) return null;
64
+ const asleep = {};
65
+ for (const [wakingStage, ids] of Object.entries(WAKES)) {
66
+ if (STAGES.indexOf(wakingStage) >= at) for (const id of ids) asleep[id] = wakingStage;
67
+ }
68
+ for (const id of WAKES_WHEN_ALIVE) asleep[id] = 'alive';
69
+ return asleep;
70
+ }
71
+
72
+ // dayOf(startedAt, now) — PURE. Day 1 is the founding day. null when unreadable, so a
73
+ // bad timestamp drops the day from the band rather than printing "day NaN".
74
+ function dayOf(startedAt, now = new Date()) {
75
+ if (!startedAt) return null;
76
+ const t = new Date(startedAt).getTime();
77
+ if (!Number.isFinite(t)) return null;
78
+ const days = Math.floor((now.getTime() - t) / 86400000) + 1;
79
+ return days >= 1 ? days : 1;
80
+ }
81
+
82
+ // shapeFoundingMode(rows, now) — PURE. rows = { active, stage, startedAt } raw strings
83
+ // (or nulls). Returns the /me payload, or null for "not founding" in any form.
84
+ function shapeFoundingMode(rows, now = new Date()) {
85
+ if (!rows || !parseActive(rows.active)) return null;
86
+ const stage = rows.stage == null ? 'decide' : String(rows.stage).trim().toLowerCase();
87
+ const asleep = asleepRooms(stage);
88
+ if (!asleep) return null; // an unknown stage is not a stage — fail closed (OFF)
89
+ const index = STAGES.indexOf(stage);
90
+ return {
91
+ active: true,
92
+ stage,
93
+ // 0 while deciding, then 1..5 through the stages proper.
94
+ stage_number: index,
95
+ stage_count: STAGES.length - 1,
96
+ day: dayOf(rows.startedAt, now),
97
+ asleep,
98
+ // Who closing the current stage is waiting on (spec D7) — display names, at most
99
+ // five, from the government module's genesis-stage sitting (task 1004429). null
100
+ // when the stage is not sitting, nobody is left to vote, or the read is missing.
101
+ waiting_on: Array.isArray(rows.waitingOn) && rows.waitingOn.length
102
+ ? rows.waitingOn.slice(0, 5).map((n) => String(n))
103
+ : null,
104
+ };
105
+ }
106
+
107
+ // foundingModeFor(deps) — the /me read. Resolves the three rows through the doorway's
108
+ // project-settings accessor (injectable for tests). Never throws: any read failure is
109
+ // "not founding".
110
+ // Three primary-key reads in parallel: the doorway's project-settings accessor is
111
+ // single-key by design (one accessor, one table), and a founding project is a short
112
+ // window of a project's life, so a batched read is not worth a second accessor.
113
+ async function foundingModeFor(deps = {}) {
114
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
115
+ let shaped;
116
+ try {
117
+ const [active, stage, startedAt] = await Promise.all([
118
+ get(KEYS.ACTIVE), get(KEYS.STAGE), get(KEYS.STARTED_AT),
119
+ ]);
120
+ shaped = shapeFoundingMode({
121
+ active: active && active.value,
122
+ stage: stage && stage.value,
123
+ startedAt: startedAt && startedAt.value,
124
+ }, deps.now || new Date());
125
+ } catch (_) {
126
+ return null;
127
+ }
128
+ if (!shaped) return null;
129
+ // The waiting-on names come from the government module's genesis-stage read
130
+ // (genesisStageStatus on its port, task 1004429: one entry per stage, each with
131
+ // a state and, while sitting, the members still to vote under the item's own
132
+ // snapshot). Optional and fail-open: a missing port or a failed read leaves the
133
+ // band without the names, never without the mode.
134
+ try {
135
+ const waitingOnFor = deps.waitingOnFor || waitingOnFromGovernment(
136
+ deps.government !== undefined ? deps.government : require('../../src/module-api').resolveOptional('government'),
137
+ );
138
+ if (waitingOnFor) {
139
+ const names = await waitingOnFor(shaped.stage);
140
+ if (Array.isArray(names) && names.length) shaped.waiting_on = names.slice(0, 5).map((n) => String(n));
141
+ }
142
+ } catch (_) { /* fail open: the band shows the stage without the names */ }
143
+ return shaped;
144
+ }
145
+
146
+ // waitingOnFromGovernment(gov) — a stage -> [display name] reader over the government
147
+ // port's genesisStageStatus(), or null when the port does not offer it. Only a stage
148
+ // that is SITTING waits on anyone; not_put, passed and returned wait on nobody.
149
+ function waitingOnFromGovernment(gov) {
150
+ if (!gov || typeof gov.genesisStageStatus !== 'function') return null;
151
+ return async (stage) => {
152
+ const status = await gov.genesisStageStatus();
153
+ const row = status && Array.isArray(status.stages) ? status.stages.find((s) => s.key === stage) : null;
154
+ if (!row || row.state !== 'sitting' || !Array.isArray(row.waiting_on)) return [];
155
+ return row.waiting_on.map((m) => m.display_name || m.github_login).filter(Boolean);
156
+ };
157
+ }
158
+
159
+ // setFoundingMode({ active, stage }, builderId, deps) — the write the genesis home
160
+ // (task 1004423) drives: start founding on a new project, move the stage, and switch
161
+ // founding off when the planet comes alive. Refuses an unknown stage rather than storing
162
+ // a value the read would then treat as OFF. Starting stamps started_at once.
163
+ async function setFoundingMode({ active, stage } = {}, builderId = null, deps = {}) {
164
+ const settings = require('../../src/module-api').projectSettings;
165
+ const get = deps.getSetting || settings.get;
166
+ const set = deps.setSetting || settings.set;
167
+ if (stage != null && !STAGES.includes(stage)) {
168
+ const err = new Error(`unknown founding stage '${stage}'`);
169
+ err.code = 'bad_founding_stage';
170
+ throw err;
171
+ }
172
+ if (active === true) {
173
+ const started = await get(KEYS.STARTED_AT);
174
+ if (!started || !started.value) await set(KEYS.STARTED_AT, (deps.now || new Date()).toISOString(), builderId);
175
+ }
176
+ if (typeof active === 'boolean') await set(KEYS.ACTIVE, active ? 'true' : 'false', builderId);
177
+ if (stage != null) await set(KEYS.STAGE, stage, builderId);
178
+ return foundingModeFor({ getSetting: get, now: deps.now });
179
+ }
180
+
181
+ module.exports = {
182
+ STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS,
183
+ parseActive, asleepRooms, dayOf, shapeFoundingMode, waitingOnFromGovernment,
184
+ foundingModeFor, setFoundingMode,
185
+ };
@@ -40,6 +40,7 @@
40
40
 
41
41
  const onboardingState = require('./onboarding-state');
42
42
  const newcomerRestock = require('./newcomer-restock');
43
+ const foundingMode = require('./founding-mode');
43
44
 
44
45
  module.exports = {
45
46
  // --- the newcomer onboarding state machine (onboarding-state.js) ---------
@@ -53,6 +54,12 @@ module.exports = {
53
54
  markStage: (...a) => onboardingState.markStage(...a),
54
55
  broadcastGraduation: (...a) => onboardingState.broadcastGraduation(...a),
55
56
 
57
+ // --- project founding mode (founding-mode.js, task 1004422) ---------------
58
+ // me.js reads it for the hall shell (null = not founding = the hall as today);
59
+ // the genesis home (task 1004423) drives the write.
60
+ foundingModeFor: (...a) => foundingMode.foundingModeFor(...a),
61
+ setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
62
+
56
63
  // --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
57
64
  // restock the just-claimed chore's shelf (claims.js / tasks.js); the Archon
58
65
  // backstop tops every chore up (tasks.js). SOURCE_TAG is the marker the claim
@@ -0,0 +1,24 @@
1
+ {
2
+ "builder": {
3
+ "id": "1",
4
+ "github_login": "builder",
5
+ "display_name": "A Builder",
6
+ "avatar_url": "/__kit/avatar.svg",
7
+ "rank": "archon"
8
+ },
9
+ "founding_mode": {
10
+ "active": true,
11
+ "stage": "strategize",
12
+ "stage_number": 2,
13
+ "stage_count": 5,
14
+ "day": 9,
15
+ "asleep": {
16
+ "work": "strategize",
17
+ "board-room": "charter",
18
+ "economy": "legalize",
19
+ "ranks": "legislation",
20
+ "collab": "alive",
21
+ "roster": "alive"
22
+ }
23
+ }
24
+ }
@@ -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.47",
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.47",
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.47",
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",
@@ -8395,5 +8395,11 @@
8395
8395
  "id": "1003141",
8396
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
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
+ }
8398
8404
  ]
8399
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.47'; // 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,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
+ });
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-09-30-commands/);
638
+ assert.match(HTML, /shell\.js\?v=2026-09-30-founding-commands/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '4e14115a3737db9a';
90
+ const SHELL_SHA = '3e4f905947bc39d2';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);