@bongos/core 1.25.18 → 1.25.20

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 (83) hide show
  1. package/.bongos-core.json +113 -83
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -1
  8. package/docs/api/openapi.json +81 -2
  9. package/docs/api-reference.md +7 -5
  10. package/docs/copy-inventory.md +219 -216
  11. package/docs/copy-registry.json +273 -246
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +348 -347
  14. package/docs/project-context.template.md +1 -1
  15. package/modules/front-door-guide/handoff.js +120 -0
  16. package/modules/front-door-guide/module.json +1 -1
  17. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  18. package/modules/hall-ui/public/ai-studio.html +1 -1
  19. package/modules/hall-ui/public/atlas.html +1 -1
  20. package/modules/hall-ui/public/blockers.html +1 -1
  21. package/modules/hall-ui/public/board-room.html +1 -1
  22. package/modules/hall-ui/public/city.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/commands.html +1 -1
  25. package/modules/hall-ui/public/copy-desk.html +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/genesis-home.js +51 -7
  28. package/modules/hall-ui/public/goal-map.html +1 -1
  29. package/modules/hall-ui/public/goals.html +1 -1
  30. package/modules/hall-ui/public/government.html +1 -1
  31. package/modules/hall-ui/public/home-city-draw.js +14 -1
  32. package/modules/hall-ui/public/idea.html +1 -1
  33. package/modules/hall-ui/public/ideas.html +1 -1
  34. package/modules/hall-ui/public/index.html +1 -1
  35. package/modules/hall-ui/public/market-watch-item.html +1 -1
  36. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  37. package/modules/hall-ui/public/market-watch.html +1 -1
  38. package/modules/hall-ui/public/modules.html +1 -1
  39. package/modules/hall-ui/public/money.html +1 -1
  40. package/modules/hall-ui/public/my-agents.html +1 -1
  41. package/modules/hall-ui/public/primer.html +1 -1
  42. package/modules/hall-ui/public/profile.html +1 -1
  43. package/modules/hall-ui/public/project-settings.html +1 -1
  44. package/modules/hall-ui/public/roster.html +1 -1
  45. package/modules/hall-ui/public/settings.html +1 -1
  46. package/modules/hall-ui/public/shell.js +32 -20
  47. package/modules/hall-ui/public/street.html +1 -1
  48. package/modules/hall-ui/public/studio.html +1 -1
  49. package/modules/hall-ui/public/style.css +9 -4
  50. package/modules/hall-ui/public/task.html +1 -1
  51. package/modules/hall-ui/public/thinking.html +1 -1
  52. package/modules/hall-ui/public/tweak-editor.html +1 -1
  53. package/modules/hall-ui/public/watch.html +1 -1
  54. package/modules/hall-ui/public/work.html +1 -1
  55. package/modules/lifecycle/home-city.js +17 -3
  56. package/modules/onboarding/founding-mode.js +45 -1
  57. package/modules/onboarding/genesis-home.js +70 -7
  58. package/modules/onboarding/genesis-work.js +68 -0
  59. package/modules/onboarding/guide-handoff.js +109 -0
  60. package/modules/onboarding/routes/genesis-work.js +11 -4
  61. package/modules/provisioning/guide-handoff.js +65 -0
  62. package/modules/provisioning/provisioning.js +2 -2
  63. package/modules/provisioning/routes/provisioning.js +4 -2
  64. package/modules/public-landing/public/projects.html +79 -0
  65. package/package-lock.json +2 -2
  66. package/package.json +1 -1
  67. package/release-notes.json +16 -0
  68. package/scripts/bongos/genesis.js +8 -1
  69. package/src/bongos/founding-page-gate.js +67 -0
  70. package/src/bongos/serve-internal.js +3 -0
  71. package/src/branding.js +4 -0
  72. package/src/module-api.js +1 -1
  73. package/tests/founding_mode.mjs +84 -8
  74. package/tests/founding_page_gate.mjs +105 -0
  75. package/tests/front_door_guide_existing.mjs +1 -0
  76. package/tests/genesis_home.mjs +105 -1
  77. package/tests/genesis_work.mjs +29 -0
  78. package/tests/genesis_work_routes.mjs +9 -0
  79. package/tests/guide_handoff.mjs +322 -0
  80. package/tests/home_city.mjs +52 -0
  81. package/tests/nav_permission_atoms.mjs +1 -1
  82. package/tests/provision_settings_apply.mjs +2 -1
  83. package/tests/provisioning_settings_env.mjs +2 -2
@@ -143,7 +143,8 @@ function makeHall() {
143
143
  documentElement: root,
144
144
  createElement: (t) => new El(t),
145
145
  querySelector: (sel) => (sel === '.app-body' ? appBody : null),
146
- querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep' ? nav.filter((a) => a.classList.contains('nav-item--asleep')) : []),
146
+ querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep, #app-sidebar .nav-item--arrived'
147
+ ? nav.filter((a) => a.classList.contains('nav-item--asleep') || a.classList.contains('nav-item--arrived')) : []),
147
148
  getElementById: (id) => nav.find((a) => a.id === id) || appBody.children.find((c) => c.id === id) || null,
148
149
  };
149
150
  return { document, nav, appBody, root };
@@ -164,12 +165,12 @@ function sliceFn(name) {
164
165
  assert.ok(start > -1 && end > start, name + ' was located in shell.js');
165
166
  return SHELL.slice(start, end + 4);
166
167
  }
167
- function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }) {
168
+ function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }, storage = null) {
168
169
  const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
169
170
  const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
170
171
  assert.ok(wakeStart > -1, 'WAKE_TEXT was located in shell.js');
171
172
  const src = [SHELL.slice(wakeStart, wakeEnd), sliceEsc(), sliceFn('applyFounding')].join('\n');
172
- return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB() });
173
+ return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB(), localStorage: storage || undefined });
173
174
  }
174
175
 
175
176
  const snapshot = (h) => JSON.stringify({
@@ -213,8 +214,8 @@ test('founding ON: the band above the topbar, and the rooms the server named asl
213
214
  const byId = (id) => h.nav.find((a) => a.id === 'nav-' + id);
214
215
  assert.ok(!byId('goals').classList.contains('nav-item--asleep'), 'scope is behind: goals is awake');
215
216
  assert.ok(byId('work').classList.contains('nav-item--asleep'), 'strategize is current: tasks still sleeps');
216
- assert.equal(byId('work').children[0].textContent, 'opens after strategy', 'a sentence, never a raw slug');
217
- assert.equal(byId('collab').children[0].textContent, 'opens when alive');
217
+ assert.equal(byId('work').children.length, 0, 'a hidden room carries no label nobody can see (task 1005112)');
218
+ assert.ok(byId('collab').classList.contains('nav-item--asleep'), 'community sleeps until alive');
218
219
  assert.ok(!byId('home').classList.contains('nav-item--asleep'), 'home is never put to sleep');
219
220
  });
220
221
 
@@ -227,7 +228,7 @@ test('founding OFF after ON restores the hall exactly — OFF removes, it does n
227
228
  assert.notEqual(snapshot(h), before);
228
229
  access.founding = null;
229
230
  apply();
230
- assert.equal(snapshot(h), before, 'band, asleep classes, wake tags and the root attribute are all gone');
231
+ assert.equal(snapshot(h), before, 'band, asleep classes and the root attribute are all gone');
231
232
  });
232
233
 
233
234
  test('the band escapes what it prints — a project name cannot inject markup', () => {
@@ -280,13 +281,13 @@ test('a failing waiting-on source never turns the mode off', async () => {
280
281
  assert.equal(f.waiting_on, null);
281
282
  });
282
283
 
283
- test('re-applying ON does not stack a second band or duplicate wake tags', () => {
284
+ test('re-applying ON does not stack a second band or add anything to a room', () => {
284
285
  const h = makeHall();
285
286
  const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'decide' }) };
286
287
  const apply = loadApplyFounding(h.document, access);
287
288
  apply(); apply();
288
289
  assert.equal(h.appBody.children.filter((c) => c.id === 'founding-band').length, 1);
289
- assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 1);
290
+ assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 0);
290
291
  });
291
292
 
292
293
  // ── the wiring ──────────────────────────────────────────────────────────────
@@ -317,3 +318,78 @@ test('founding mode adds no page, no route and no nav item — it is a mode', ()
317
318
  const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
318
319
  assert.ok(!hall.some((f) => /^founding\./.test(f)), 'no founding page file');
319
320
  });
321
+
322
+ // ── task 1005112: hidden, not dimmed; the arrival; the honest refusal ─────────
323
+
324
+ test('a sleeping room is hidden by the sheet, and a group of sleeping rooms hides its head', () => {
325
+ const css = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'style.css'), 'utf8');
326
+ assert.match(css, /\.nav-item--asleep \{ display: none; \}/, 'asleep is not drawn at all, never dimmed');
327
+ assert.doesNotMatch(css, /\.nav-item--asleep \{ opacity/);
328
+ assert.match(css, /html\[data-founding="on"\] \.nav-group:not\(:has\(\.nav-item:not\(\.nav-item--asleep\)/);
329
+ });
330
+
331
+ test('a room that woke since this browser last looked arrives marked, once (task 1005112)', () => {
332
+ const store = new Map();
333
+ const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
334
+ const at = (stage) => ({ founding: fm.shapeFoundingMode({ active: 'true', stage }) });
335
+ const h1 = makeHall();
336
+ loadApplyFounding(h1.document, at('scope'), undefined, storage)();
337
+ assert.ok(!h1.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the first look marks nothing');
338
+ const h2 = makeHall();
339
+ loadApplyFounding(h2.document, at('strategize'), undefined, storage)();
340
+ const arrived = h2.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id);
341
+ assert.deepEqual(arrived.sort(), ['nav-goal-map', 'nav-goals'], 'scope finished: Goals and Sky arrive');
342
+ const h3 = makeHall();
343
+ loadApplyFounding(h3.document, at('strategize'), undefined, storage)();
344
+ assert.ok(!h3.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the next page load: no mark again');
345
+ // Switching off removes the mark with everything else.
346
+ const access = at('charter');
347
+ const h4 = makeHall();
348
+ const before = snapshot(h4);
349
+ const apply = loadApplyFounding(h4.document, access, undefined, storage);
350
+ apply();
351
+ access.founding = null;
352
+ apply();
353
+ assert.equal(snapshot(h4), before);
354
+ });
355
+
356
+ test('the shell says, on Home, what a refused room is and which stage opens it (task 1005112)', () => {
357
+ const navStart = SHELL.indexOf(' var NAV = [');
358
+ const navEnd = SHELL.indexOf(' var NAV_COLLAPSE_KEY');
359
+ const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
360
+ const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
361
+ const src = [SHELL.slice(navStart, navEnd), SHELL.slice(wakeStart, wakeEnd), sliceEsc(),
362
+ sliceFn('navItemById'), sliceFn('renderDeniedNotice')].join('\n');
363
+ let inserted = null;
364
+ let replaced = null;
365
+ const el = { querySelector: () => ({ addEventListener() {} }), setAttribute() {} };
366
+ const document = {
367
+ getElementById: (id) => (id === 'app-topbar' ? { insertAdjacentElement: (_, n) => { inserted = n; } } : null),
368
+ createElement: () => el,
369
+ };
370
+ const run = (search) => {
371
+ inserted = null;
372
+ new Function('document', 'window', 'history', 'brand', 'access', 'icon', 'chevronIcon',
373
+ `${src}\nreturn renderDeniedNotice;`)(document,
374
+ { location: { search, pathname: '/', hash: '' }, OTB: realOTB() },
375
+ { replaceState: (a, b, url) => { replaced = url; } }, {}, {}, () => '', () => '')();
376
+ return inserted;
377
+ };
378
+ const n = run('?asleep=goals&opens=scope');
379
+ assert.ok(n, 'a notice is drawn');
380
+ assert.match(n.innerHTML, /Goals isn’t open yet\. It opens after scope\. Finish that stage of founding here on Home\./);
381
+ assert.equal(replaced, '/', 'the query is stripped so a refresh does not re-show it');
382
+ assert.equal(run('?asleep=goals%3Cx&opens=scope'), null, 'a malformed room id draws nothing');
383
+ assert.match(run('?denied=page.view.government').innerHTML, /permission/, 'the old denial still reads the same');
384
+ });
385
+
386
+ test('the arrival mark survives the shell applying the mode a second time on one page', () => {
387
+ const store = new Map([['hall-founding-asleep', JSON.stringify({ goals: 'scope', 'goal-map': 'scope', 'board-room': 'charter' })]]);
388
+ const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
389
+ const h = makeHall();
390
+ const apply = loadApplyFounding(h.document, { founding: fm.shapeFoundingMode({ active: 'true', stage: 'strategize' }) }, undefined, storage);
391
+ apply();
392
+ apply(); // /me settles, then the page reports: applyAccess runs again
393
+ const arrived = h.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id).sort();
394
+ assert.deepEqual(arrived, ['nav-board-room', 'nav-goal-map', 'nav-goals']);
395
+ });
@@ -0,0 +1,105 @@
1
+ // tests/founding_page_gate.mjs — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
2
+ //
3
+ // The sidebar hides a room a founding project has not opened; this is the other half,
4
+ // the page. Everything here RUNS the real middleware (src/bongos/founding-page-gate.js)
5
+ // over fake requests, fed by the real founding-mode shape (modules/onboarding/
6
+ // founding-mode.js) — and, in the last cases, through the real port lookup — so the
7
+ // server's /me answer and the page refusal are one rule.
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { foundingPageGate } = require('../src/bongos/founding-page-gate.js');
16
+ const seams = require('../src/module-seams.js');
17
+ const fm = require('../modules/onboarding/founding-mode.js');
18
+
19
+ const founding = (stage) => fm.shapeFoundingMode({ active: 'true', stage });
20
+ const ROOMS = [...new Set([...Object.values(fm.WAKES).flat(), ...fm.WAKES_WHEN_ALIVE])];
21
+
22
+ // Drive the middleware once; resolve with what it did.
23
+ function run(pathname, { read, buildersHost = false, skip = false } = {}) {
24
+ return new Promise((resolve) => {
25
+ const res = {
26
+ headers: {},
27
+ set(k, v) { this.headers[k] = v; return this; },
28
+ redirect(code, url) { resolve({ redirect: url, code, headers: this.headers }); },
29
+ };
30
+ const opts = { isBuildersHost: () => buildersHost, skip: () => skip };
31
+ if (read) opts.read = read;
32
+ foundingPageGate(opts)({ path: pathname }, res, () => resolve({ next: true }));
33
+ });
34
+ }
35
+
36
+ test('every room a genesis stage can put to sleep is refused while it sleeps, and nothing else is', async () => {
37
+ // At 'decide' every room sleeps, so this is the whole list the gate must cover.
38
+ const read = async () => founding('decide');
39
+ for (const id of ROOMS) {
40
+ const out = await run(`/builders/${id}`, { read });
41
+ assert.ok(out.redirect, `${id} is refused while asleep`);
42
+ }
43
+ for (const never of ['primer', 'board-room', 'profile', 'task/12', 'settings', 'blockers', 'studio']) {
44
+ assert.deepEqual(await run(`/builders/${never}`, { read }), { next: true }, `${never} is never gated`);
45
+ }
46
+ // And the gate names no room the server never puts to sleep: a fake answer naming
47
+ // an unknown room changes nothing.
48
+ assert.deepEqual(await run('/builders/blockers', { read: async () => ({ active: true, asleep: { blockers: 'scope' } }) }), { next: true });
49
+ });
50
+
51
+ test('a sleeping room\'s page goes home, naming the room and the stage that opens it', async () => {
52
+ const out = await run('/builders/goals', { read: async () => founding('scope') });
53
+ assert.equal(out.code, 302);
54
+ assert.equal(out.redirect, '/builders/?asleep=goals&opens=scope');
55
+ assert.equal(out.headers['Cache-Control'], 'no-store', 'a refusal is never cached past the wake');
56
+ const sub = await run('/builders/work.html', { read: async () => founding('scope'), buildersHost: true });
57
+ assert.equal(sub.redirect, '/?asleep=work&opens=strategize', 'the builders. host form');
58
+ const alive = await run('/builders/roster/', { read: async () => founding('legislation') });
59
+ assert.equal(alive.redirect, '/builders/?asleep=roster&opens=alive');
60
+ const sneaky = await run('/builders//goal-map', { read: async () => founding('decide') });
61
+ assert.equal(sneaky.redirect, '/builders/?asleep=goal-map&opens=scope', 'a doubled slash is normalised, not a way past');
62
+ });
63
+
64
+ test('an awake room opens, and a later stage never takes one away', async () => {
65
+ assert.deepEqual(await run('/builders/goals', { read: async () => founding('strategize') }), { next: true });
66
+ assert.deepEqual(await run('/builders/work', { read: async () => founding('charter') }), { next: true });
67
+ });
68
+
69
+ test('a project that is not founding is untouched, and a failed read opens rather than shuts', async () => {
70
+ assert.deepEqual(await run('/builders/goals', { read: async () => null }), { next: true });
71
+ assert.deepEqual(await run('/builders/collab', { read: async () => ({ active: false }) }), { next: true });
72
+ assert.deepEqual(await run('/builders/goals', { read: async () => { throw new Error('db down'); } }), { next: true },
73
+ 'a read that throws opens the page');
74
+ });
75
+
76
+ test('only the five room pages ever cost a founding read', async () => {
77
+ let reads = 0;
78
+ const read = async () => { reads += 1; return founding('decide'); };
79
+ for (const p of ['/builders/', '/builders/primer', '/builders/board-room', '/builders/task/5', '/api/bongos/me', '/goals']) {
80
+ assert.deepEqual(await run(p, { read }), { next: true }, p);
81
+ }
82
+ assert.deepEqual(await run('/builders/goals', { read, skip: true }), { next: true }, 'a module surface host is skipped');
83
+ assert.equal(reads, 0);
84
+ });
85
+
86
+ test('the default read is the onboarding port\'s foundingModeFor, the answer /me carries', async () => {
87
+ seams._reset();
88
+ try {
89
+ assert.deepEqual(await run('/builders/goals'), { next: true }, 'onboarding module off: not founding, page opens');
90
+ seams.registerProvider('onboarding', { foundingModeFor: async () => founding('scope') });
91
+ assert.equal((await run('/builders/goals')).redirect, '/builders/?asleep=goals&opens=scope');
92
+ seams._reset();
93
+ seams.registerProvider('onboarding', { foundingModeFor: async () => { throw new Error('settings table gone'); } });
94
+ assert.deepEqual(await run('/builders/goals'), { next: true }, 'a port that throws opens the page');
95
+ } finally {
96
+ seams._reset();
97
+ }
98
+ });
99
+
100
+ test('the server mounts it after the sign-in gate, so a stranger still meets sign-in first', () => {
101
+ const src = fs.readFileSync(new URL('../src/bongos/serve-internal.js', import.meta.url), 'utf8');
102
+ const signIn = src.indexOf('return gdsAuth.requireBuilderPage(req, res, next);');
103
+ const mount = src.indexOf('app.use(foundingPageGate(');
104
+ assert.ok(signIn > 0 && mount > signIn, 'mounted, and after the sign-in gate');
105
+ });
@@ -222,6 +222,7 @@ test('the guide has no upload, fetch or link-reading path: these are all its rou
222
222
  assert.deepEqual([...routes(chatRouter), ...routes(planRouter)].sort(), [
223
223
  'GET /front-door-guide/conversations/:id',
224
224
  'GET /front-door-guide/mine',
225
+ 'GET /front-door-guide/mine/handoff.md', // the plan as the hand-off file, a download (task 1005274): reads nothing of theirs
225
226
  'GET /front-door-guide/status',
226
227
  'POST /front-door-guide/conversations',
227
228
  'POST /front-door-guide/conversations/:id/attach',
@@ -226,7 +226,7 @@ function world({ withGovernment = true } = {}) {
226
226
  const deps = () => ({
227
227
  getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
228
228
  setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
229
- lifecycle, government, now,
229
+ lifecycle, government, now, documents: null,
230
230
  });
231
231
  return {
232
232
  store, tasks, board, opened, deps,
@@ -385,6 +385,67 @@ test('with no Board Room module the founder\'s confirm closes a finished stage',
385
385
  assert.equal((await gh.readGenesis(w.deps())).stage, 'strategize');
386
386
  });
387
387
 
388
+ test('task 1005115: with no Board Room a finished stage moves on by itself, and the view says so', async () => {
389
+ const w = world({ withGovernment: false });
390
+ await gh.startFounding(1, w.deps());
391
+ await gh.savePlan({}, 1, w.deps());
392
+ let g = await gh.readGenesis(w.deps());
393
+ assert.equal(g.board_room, false);
394
+ assert.equal(g.moved, null, 'nothing has moved past a stage yet');
395
+ w.finish('scope');
396
+ // A ship moves it on; no read and no button needed.
397
+ assert.deepEqual(await gh.onTaskShipped(w.deps()), []);
398
+ assert.equal(w.store.get(fm.KEYS.STAGE), 'strategize', 'the ship that finished scope advanced it');
399
+ g = await gh.readGenesis(w.deps());
400
+ assert.equal(g.stage, 'strategize');
401
+ assert.equal(g.can_close, false, 'there is no close button on this path');
402
+ assert.equal(g.moved.from, 'scope');
403
+ assert.equal(g.moved.to, 'strategize');
404
+ assert.equal(g.moved.how, 'tasks_finished');
405
+ assert.deepEqual(g.moved.opened, fm.WAKES.scope, 'and it names what opened');
406
+ assert.equal(g.stages[0].state, 'done');
407
+ // Every read reconciles too, through several finished stages in order.
408
+ w.finish('strategize'); w.finish('charter');
409
+ g = await gh.readGenesis(w.deps());
410
+ assert.equal(g.stage, 'legalize');
411
+ // Legislation does not pass itself: its finished tasks open the alive door.
412
+ w.finish('legalize'); w.finish('legislation');
413
+ g = await gh.readGenesis(w.deps());
414
+ assert.equal(g.stage, 'legislation');
415
+ assert.equal(g.can_close, false);
416
+ assert.equal(g.can_come_alive, true);
417
+ // The old close call on a stage that already moved answers what happened.
418
+ const again = await gh.closeStage('scope', 1, w.deps());
419
+ assert.equal(again.ok, true);
420
+ assert.equal(again.already, true);
421
+ assert.equal(again.next_stage, 'legislation', 'it names where the project is now, not the stage after scope');
422
+ assert.equal((await gh.comeAlive(1, w.deps())).ok, true);
423
+ });
424
+
425
+ test('task 1005115: with a Board Room nothing moves by itself; the sitting still decides', async () => {
426
+ const w = world();
427
+ await gh.startFounding(1, w.deps());
428
+ await gh.savePlan({}, 1, w.deps());
429
+ w.finish('scope');
430
+ const g = await gh.readGenesis(w.deps());
431
+ assert.equal(g.stage, 'scope');
432
+ assert.equal(g.board_room, true);
433
+ assert.equal(g.can_close, true);
434
+ });
435
+
436
+ test('task 1005115: each stage on the genesis home carries its document', async () => {
437
+ const w = world({ withGovernment: false });
438
+ await gh.startFounding(1, w.deps());
439
+ await gh.savePlan({}, 1, w.deps());
440
+ const documents = async () => ({ scope: { title: 'The scope', filled: true, updated_at: null, editable: true } });
441
+ let g = await gh.readGenesis({ ...w.deps(), documents });
442
+ assert.equal(g.stages[0].document.title, 'The scope');
443
+ assert.equal(g.stages[1].document, null);
444
+ g = await gh.readGenesis({ ...w.deps(), documents: async () => { throw new Error('db down'); } });
445
+ assert.equal(g.stage, 'scope', 'the home still renders when the documents cannot be read');
446
+ assert.equal(g.stages[0].document, null);
447
+ });
448
+
388
449
  test('the view: states, wakes, the life plates and a due date from the plan', () => {
389
450
  const mode = { stage: 'strategize', stage_number: 2, stage_count: 5, day: 9 };
390
451
  const g = gh.shapeGenesis({
@@ -592,6 +653,8 @@ test('the stage reads as a checklist: done, open and locked, and a locked row of
592
653
  assert.match(row('302'), /class="genesis__task is-locked"/);
593
654
  assert.match(row('302'), /after Gather what already exists/, 'a locked row says what it waits on');
594
655
  assert.doesNotMatch(row('302'), /data-genesis-copy/, 'a locked row offers no prompt for work it cannot claim');
656
+ assert.doesNotMatch(row('301'), /sr-only/, 'an open row adds no hidden word its status pill could contradict');
657
+ assert.match(row('302'), /class="sr-only">Locked: /);
595
658
  assert.doesNotMatch(html, /is-finished|line-through/, 'nothing is drawn struck through');
596
659
  // The "done" row: the earlier stage is not the one on screen, so check the shape directly.
597
660
  const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
@@ -605,6 +668,25 @@ test('the stage reads as a checklist: done, open and locked, and a locked row of
605
668
  assert.doesNotMatch(r304, /data-genesis-copy/);
606
669
  });
607
670
 
671
+ test('task 1005115: with no Board Room the card says the stage moved by itself, and lists what each stage left', async () => {
672
+ const genesis = gh.shapeGenesis({
673
+ mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
674
+ plan: { days: {} },
675
+ tasks: { scope: [{ id: '1', title: 'Write the scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Write the strategy', status: 'ready' }] },
676
+ stageAt: { strategize: '2026-10-08T00:00:00Z' },
677
+ boardRoom: false,
678
+ documents: { scope: { title: 'The scope', filled: true, updated_at: null, editable: true }, strategize: { title: 'The strategy', filled: false } },
679
+ });
680
+ const { host } = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
681
+ await new Promise((r) => setImmediate(r));
682
+ await new Promise((r) => setImmediate(r));
683
+ assert.match(host.innerHTML, /data-genesis-moved>Scope is done, so this stage opened by itself\. Now open: /);
684
+ assert.match(host.innerHTML, /moves on by itself/);
685
+ assert.doesNotMatch(host.innerHTML, /put this stage to the board/, 'no board to put it to');
686
+ assert.match(host.innerHTML, /<b>The scope<\/b>/);
687
+ assert.doesNotMatch(host.innerHTML, /The strategy<\/b>/, 'an empty document is not listed');
688
+ });
689
+
608
690
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
609
691
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
610
692
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -694,3 +776,25 @@ test('task 1005121: the close route answers 202 with the whole body for an open
694
776
  const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
695
777
  assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
696
778
  });
779
+
780
+ test('a stage that opens no room says what finishing it earns, never nothing (task 1005112)', async () => {
781
+ const genesis = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan });
782
+ const by = Object.fromEntries(genesis.stages.map((s) => [s.key, s]));
783
+ assert.equal(by.scope.earns, null, 'a stage that wakes rooms names the rooms instead');
784
+ assert.equal(by.strategize.earns, null);
785
+ for (const k of ['charter', 'legalize', 'legislation']) {
786
+ assert.deepEqual(by[k].wakes, [], `${k} wakes no room`);
787
+ assert.ok(by[k].earns && by[k].earns.length > 10, `${k} says what it earns`);
788
+ }
789
+ assert.match(by.legislation.earns, /every room opens/, 'the last stage is the one that opens everything');
790
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
791
+ await mountClickable(b);
792
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map/);
793
+ assert.match(b.host.innerHTML, /puts your government in force/);
794
+ assert.match(b.host.innerHTML, /settles how contributors are rewarded/);
795
+ // The district rises with the stages (task 1005112): each row also names what it raises.
796
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map · raises Planning/);
797
+ assert.match(b.host.innerHTML, /Raises Treasury · settles how contributors are rewarded/);
798
+ assert.match(b.host.innerHTML, /Raises Rules, Review, Disputes · brings the project to life/);
799
+ assert.deepEqual(by.legalize.raises, ['treasury']);
800
+ });
@@ -370,6 +370,35 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
370
370
  assert.match(out, /\(empty\)/);
371
371
  });
372
372
 
373
+ await test('task 1005115: a stage\'s document is its record read as one named, editable page', async () => {
374
+ const deps = { store: memStore() };
375
+ let d = (await gw.readDocument('strategize', deps)).document;
376
+ assert.equal(d.title, 'The strategy');
377
+ assert.equal(d.filled, false, 'nothing written yet: an empty document, not a heading');
378
+ assert.equal(d.markdown, '');
379
+ await gw.setAnswer('strategize', 'big_pieces', { text: 'Ledger, then sync' }, 1, deps);
380
+ await gw.setAnswer('strategize', 'risks', { state: 'not_sure', text: 'bank API' }, 1, deps);
381
+ await gw.setPage('strategize', 'We start small.', 1, deps);
382
+ d = (await gw.readDocument('strategize', deps)).document;
383
+ assert.equal(d.filled, true);
384
+ assert.match(d.markdown, /^# The strategy\n/);
385
+ assert.match(d.markdown, /Ledger, then sync/);
386
+ assert.match(d.markdown, /Not sure yet — bank API/);
387
+ assert.match(d.markdown, /We start small\./);
388
+ assert.equal(d.editable, true);
389
+ assert.equal(d.edit.page, 'PUT /api/bongos/founding/work/strategize/page');
390
+ assert.ok(d.updated_at, 'the document says when it last changed');
391
+ // Editing it is editing the record: the same write, seen at once in the document.
392
+ await gw.setAnswer('strategize', 'big_pieces', { text: 'Sync first' }, 1, deps);
393
+ assert.match((await gw.readDocument('strategize', deps)).document.markdown, /Sync first/);
394
+ assert.equal((await gw.readDocument('nope', deps)).reason, 'unknown_stage');
395
+ const all = await gw.readDocuments(deps);
396
+ assert.deepEqual(Object.keys(all), ['scope', 'strategize', 'charter', 'legalize', 'legislation']);
397
+ assert.equal(all.strategize.filled, true);
398
+ assert.equal(all.scope.filled, false);
399
+ assert.equal(all.charter.title, 'The charter');
400
+ });
401
+
373
402
  console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
374
403
  if (failed) process.exit(1);
375
404
  }
@@ -67,6 +67,15 @@ try {
67
67
  assert.equal(r.data.work.questions.find((q) => q.id === 'big_pieces').answer.text, 'Ledger first.');
68
68
  });
69
69
 
70
+ await test('task 1005115: any builder reads a stage\'s document; an unknown stage is 404', async () => {
71
+ assert.equal((await call('GET', '/founding/work/strategize/document', { as: null })).status, 401);
72
+ const r = await call('GET', '/founding/work/strategize/document', { as: OTHER });
73
+ assert.equal(r.status, 200);
74
+ assert.equal(r.data.document.title, 'The strategy');
75
+ assert.match(r.data.document.markdown, /Ledger first\./, 'the answer written above is in the document');
76
+ assert.equal((await call('GET', '/founding/work/nope/document')).status, 404);
77
+ });
78
+
70
79
  await test('a builder who is not the owner cannot write', async () => {
71
80
  for (const [m, p, body] of [
72
81
  ['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],