@bongos/core 1.24.73 → 1.24.75

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 (67) hide show
  1. package/.bongos-core.json +90 -70
  2. package/docs/api/openapi.json +1 -1
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +13 -13
  5. package/docs/copy-registry.json +15 -15
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-inventory.json +33 -0
  8. package/docs/page-readings.json +39 -39
  9. package/modules/government/board.js +10 -4
  10. package/modules/government/db.js +7 -5
  11. package/modules/government/genesis-close.js +45 -0
  12. package/modules/government/migrations/government_038_vote_cast_via.sql +23 -0
  13. package/modules/government/routes/government.js +4 -5
  14. package/modules/hall-ui/public/ai-studio.html +2 -1
  15. package/modules/hall-ui/public/atlas.html +2 -1
  16. package/modules/hall-ui/public/blockers.html +2 -1
  17. package/modules/hall-ui/public/board-room.html +2 -1
  18. package/modules/hall-ui/public/board-room.js +1 -0
  19. package/modules/hall-ui/public/city.html +2 -1
  20. package/modules/hall-ui/public/collab.html +2 -1
  21. package/modules/hall-ui/public/commands.html +2 -1
  22. package/modules/hall-ui/public/copy-desk.html +2 -1
  23. package/modules/hall-ui/public/deploy.html +2 -1
  24. package/modules/hall-ui/public/genesis-home.js +7 -12
  25. package/modules/hall-ui/public/goal-map.html +2 -1
  26. package/modules/hall-ui/public/goals.html +2 -1
  27. package/modules/hall-ui/public/government.html +2 -1
  28. package/modules/hall-ui/public/idea.html +2 -1
  29. package/modules/hall-ui/public/ideas.html +2 -1
  30. package/modules/hall-ui/public/index.html +2 -1
  31. package/modules/hall-ui/public/market-watch-item.html +2 -1
  32. package/modules/hall-ui/public/market-watch-rules.html +2 -1
  33. package/modules/hall-ui/public/market-watch.html +2 -1
  34. package/modules/hall-ui/public/modules.html +2 -1
  35. package/modules/hall-ui/public/money.html +2 -1
  36. package/modules/hall-ui/public/my-agents.html +2 -1
  37. package/modules/hall-ui/public/primer.html +2 -1
  38. package/modules/hall-ui/public/profile.html +2 -1
  39. package/modules/hall-ui/public/project-settings.html +2 -1
  40. package/modules/hall-ui/public/roster.html +2 -1
  41. package/modules/hall-ui/public/settings.html +2 -1
  42. package/modules/hall-ui/public/shell-game.js +50 -0
  43. package/modules/hall-ui/public/shell.js +11 -11
  44. package/modules/hall-ui/public/street.html +2 -1
  45. package/modules/hall-ui/public/studio.html +2 -1
  46. package/modules/hall-ui/public/task.html +2 -1
  47. package/modules/hall-ui/public/thinking.html +2 -1
  48. package/modules/hall-ui/public/tweak-editor.html +2 -1
  49. package/modules/hall-ui/public/watch.html +2 -1
  50. package/modules/hall-ui/public/work.html +2 -1
  51. package/modules/hall-ui/records/game-mode.md +20 -0
  52. package/modules/onboarding/genesis-home.js +65 -8
  53. package/modules/onboarding/routes/onboarding.js +15 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/release-notes.json +12 -0
  57. package/scripts/bongos/government-walk.js +3 -3
  58. package/src/module-api.js +1 -1
  59. package/tests/genesis_home.mjs +86 -1
  60. package/tests/goal_map_page.mjs +2 -2
  61. package/tests/government_board_genesis_stage.mjs +83 -0
  62. package/tests/government_seed.mjs +1 -0
  63. package/tests/hall_city.mjs +1 -1
  64. package/tests/hall_fullscreen.mjs +101 -0
  65. package/tests/hall_my_agents.mjs +1 -1
  66. package/tests/hall_street.mjs +1 -1
  67. package/tests/nav_permission_atoms.mjs +1 -1
@@ -326,10 +326,25 @@ async function applyGovernmentChoice(form, builderId, s) {
326
326
  return { state: 'not_filed', form, reason };
327
327
  }
328
328
 
329
- // closeStage — put the current stage to the Board Room. The finished-tasks gate is the
330
- // government module's (it checks the ids against the board); this only names the
331
- // stage's tasks. With no government module there is no Board Room to put it to, and
332
- // the founder's confirm closes the stage itself once its tasks are finished.
329
+ // closeStage — CLOSE the current stage, in one call (task 1005121; owner ruling
330
+ // 2026-11-07: "closing a stage shouldn't be such a big thing"). The answer says, by
331
+ // itself, which of two things happened — a caller never needs a second request:
332
+ //
333
+ // { ok: true, closed: true, stage, next_stage, item_id, confirm }
334
+ // The stage is closed and the project has moved on (`next_stage` null at
335
+ // legislation: the alive door, POST /founding/alive, is open instead).
336
+ // `confirm` is 'stage_close' when the founder's own yes carried it — the
337
+ // government port records that HOW on the vote (cast_via).
338
+ // { ok: false, closed: false, reason: 'sitting_open', stage, item_id, already_open,
339
+ // waiting: { pass_rule, needs, voted, waiting_on, closes_at, remaining_seconds },
340
+ // next }
341
+ // A real Board Room sitting is deciding it (more than one person, or a rule
342
+ // one yes cannot carry). Never ok: true — the stage did NOT close. The route
343
+ // answers 202 with this whole body; `next` says what to do, in words.
344
+ //
345
+ // With a government module the port's closeGenesisStage does the deciding (its
346
+ // finished-tasks gate, the founder's confirm where one person decides). With none,
347
+ // the founder's ask closes a finished stage itself. Same rule at every stage.
333
348
  async function closeStage(stageKey, builderId, deps = {}) {
334
349
  const s = seams(deps);
335
350
  const mode = await reconcile(s);
@@ -337,17 +352,59 @@ async function closeStage(stageKey, builderId, deps = {}) {
337
352
  if (mode.stage !== stageKey) return { ok: false, reason: 'not_current_stage', stage: mode.stage };
338
353
  const all = s.lifecycle && typeof s.lifecycle.genesisTasks === 'function' ? await s.lifecycle.genesisTasks() : {};
339
354
  const list = all[stageKey] || [];
355
+ const taskIds = list.map((t) => t.id);
340
356
  const gov = s.government;
357
+ const nextOf = (key) => (key === 'legislation' ? null : fm.STAGES[fm.STAGES.indexOf(key) + 1]);
358
+ const closedAnswer = async (itemId, confirm) => {
359
+ const after = await reconcile(s);
360
+ return {
361
+ ok: true, closed: true, stage: stageKey,
362
+ next_stage: stageKey === 'legislation' ? null : (after && after.stage) || nextOf(stageKey),
363
+ item_id: itemId, confirm,
364
+ };
365
+ };
366
+ if (gov && typeof gov.closeGenesisStage === 'function') {
367
+ const r = await gov.closeGenesisStage({ stageKey, taskIds, closedBy: builderId });
368
+ if (r.reason) return { ok: false, reason: r.reason, unfinished: r.unfinished || [] };
369
+ const itemId = r.item ? String(r.item.id) : null;
370
+ if (r.closed) return closedAnswer(itemId, r.cast ? r.cast.cast_via : null);
371
+ return sittingOpen(stageKey, itemId, !r.opened, r.status, builderId);
372
+ }
341
373
  if (gov && typeof gov.openGenesisStageClose === 'function') {
342
- const r = await gov.openGenesisStageClose({ stageKey, taskIds: list.map((t) => t.id), openedBy: builderId });
374
+ // An older government module without the one-call close: the sitting opens and
375
+ // the answer says so, rather than claiming a close that did not happen.
376
+ const r = await gov.openGenesisStageClose({ stageKey, taskIds, openedBy: builderId });
343
377
  if (!r.opened && r.reason !== 'already_open') return { ok: false, reason: r.reason, unfinished: r.unfinished || [] };
344
- return { ok: true, item_id: r.item ? String(r.item.id) : null, already_open: !r.opened };
378
+ const board = await boardByStage(gov);
379
+ return sittingOpen(stageKey, r.item ? String(r.item.id) : null, !r.opened, board[stageKey] || null, builderId);
345
380
  }
346
381
  if (!list.length || !list.every((t) => FINISHED.includes(t.status))) return { ok: false, reason: 'stage_tasks_unfinished' };
347
382
  if (stageKey !== 'legislation') {
348
- await fm.setFoundingMode({ stage: fm.STAGES[fm.STAGES.indexOf(stageKey) + 1] }, builderId, modeDeps(s));
383
+ await fm.setFoundingMode({ stage: nextOf(stageKey) }, builderId, modeDeps(s));
349
384
  }
350
- return { ok: true, item_id: null, closed: true };
385
+ return { ok: true, closed: true, stage: stageKey, next_stage: nextOf(stageKey), item_id: null, confirm: null };
386
+ }
387
+
388
+ // The "a sitting is deciding it" answer: what happened, what it waits on, what next.
389
+ function sittingOpen(stageKey, itemId, alreadyOpen, status, builderId) {
390
+ const st = status || {};
391
+ const waitingOn = Array.isArray(st.waiting_on) ? st.waiting_on : [];
392
+ const names = waitingOn.map((m) => m.display_name || m.github_login || `builder ${m.builder_id}`);
393
+ const callerWaited = waitingOn.some((m) => String(m.builder_id) === String(builderId));
394
+ let next = `The Board Room is deciding this stage (sitting ${itemId || 'open'}).`;
395
+ if (st.needs) next += ` Its rule: ${st.needs}`;
396
+ if (names.length) next += ` Still to vote: ${names.join(', ')}.`;
397
+ if (callerWaited && itemId) next += ` Your vote is one of them: POST /api/bongos/government/board/items/${itemId}/votes { "direction": "yes" }.`;
398
+ next += ' The stage moves on by itself when the sitting passes; there is nothing else to call.';
399
+ return {
400
+ ok: false, closed: false, reason: 'sitting_open', stage: stageKey, item_id: itemId, already_open: !!alreadyOpen,
401
+ waiting: {
402
+ pass_rule: st.pass_rule || null, needs: st.needs || null,
403
+ voted: Array.isArray(st.voted) ? st.voted : [], waiting_on: waitingOn,
404
+ closes_at: st.closes_at || null, remaining_seconds: st.remaining_seconds ?? null,
405
+ },
406
+ next,
407
+ };
351
408
  }
352
409
 
353
410
  // comeAlive — the last door. Only once legislation has passed (or, with no Board Room,
@@ -170,6 +170,20 @@ function mountGenesisRoutes(router) {
170
170
  })) return;
171
171
  next();
172
172
  }, handle((req) => genesis.savePlan(req.body || {}, req.builder.id)));
173
- router.post('/founding/stages/:stage/close', api.requireBuilder, owns, handle((req) => genesis.closeStage(String(req.params.stage), req.builder.id)));
173
+ // task 1005121: one call. 200 { closed: true, next_stage } when the stage closed;
174
+ // 202 { ok: false, closed: false, reason: 'sitting_open', waiting, next } when a
175
+ // real sitting is deciding it — never a success for a stage that did not close.
176
+ router.post('/founding/stages/:stage/close', api.requireBuilder, owns, async (req, res) => {
177
+ try {
178
+ const r = await genesis.closeStage(String(req.params.stage), req.builder.id);
179
+ res.set('Cache-Control', 'no-store');
180
+ if (r && r.reason === 'sitting_open') return res.status(202).json(r);
181
+ if (r && r.ok === false) return refuse(res, r);
182
+ res.json(r);
183
+ } catch (err) {
184
+ api.logger('onboarding').error('[onboarding] genesis', err);
185
+ res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
186
+ }
187
+ });
174
188
  router.post('/founding/alive', api.requireBuilder, owns, handle((req) => genesis.comeAlive(req.builder.id)));
175
189
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.73",
3
+ "version": "1.24.75",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.73",
9
+ "version": "1.24.75",
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.24.73",
3
+ "version": "1.24.75",
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",
@@ -11463,5 +11463,17 @@
11463
11463
  "id": "1005140",
11464
11464
  "text": "When a project's board votes in a new form of government, that choice now stays in force after the server restarts. Before, it was quietly lost on every restart and the project fell back to the original one-person rule. A proj"
11465
11465
  }
11466
+ ],
11467
+ "1.24.74": [
11468
+ {
11469
+ "id": "1005142",
11470
+ "text": "The hall has a game mode: once it is on, every page opens full screen with no sidebar, and Escape brings the menus back when you need them."
11471
+ }
11472
+ ],
11473
+ "1.24.75": [
11474
+ {
11475
+ "id": "1005121",
11476
+ "text": "Closing a project-setup stage is now one step: if the founder alone decides, the stage closes and the project moves on at once; if others must vote, the answer says who and what to do next instead of falsely reporting success."
11477
+ }
11466
11478
  ]
11467
11479
  }
@@ -801,12 +801,12 @@ async function runWalk() {
801
801
  for (const stage of genesis.PROPER) {
802
802
  store.tasks.get(STAGE_TASK_IDS[stage]).status = 'shipped';
803
803
  let itemId = null;
804
- await check(`${stage}: its finished stage is put to the board`, 'a sitting under the chosen government', async () => {
804
+ await check(`${stage}: its finished stage is put to the board`, '202 not closed, a sitting under the chosen government', async () => {
805
805
  const r = await call(FOUNDER, 'POST', `/founding/stages/${stage}/close`);
806
806
  itemId = r.json && r.json.item_id;
807
807
  const snap = itemId && itemOf(itemId) ? itemOf(itemId).constitution : {};
808
- const reads = charter.readCharter(snap).form;
809
- return { got: `${r.status}, sitting under ${reads}`, pass: r.status === 200 && !!itemId && reads === CHOSEN };
808
+ const reads = charter.readCharter(snap).form, notClosed = !!r.json && r.json.closed === false && r.json.reason === 'sitting_open'; // task 1005121: never a 200 for an open stage
809
+ return { got: `${r.status} ${notClosed ? 'not closed' : 'closed?'}, a sitting under ${reads}`, pass: r.status === 202 && notClosed && !!itemId && reads === CHOSEN };
810
810
  });
811
811
  await check(`${stage}: the founder alone cannot close it; every trusted member's yes does`, 'open, open, passed', async () => {
812
812
  const after = await voteAll(itemId, timocrats);
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.73'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.75'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -205,8 +205,13 @@ test('decide → five stages through the Board Room → alive, and the hall open
205
205
  w.finish(s);
206
206
  assert.equal((await gh.readGenesis(w.deps())).can_close, true);
207
207
  const closed = await gh.closeStage(s, 1, w.deps());
208
- assert.equal(closed.ok, true);
208
+ // task 1005121: a module without the one-call close only OPENS a sitting, and
209
+ // the answer must say so — never ok: true for a stage that did not close.
210
+ assert.equal(closed.ok, false);
211
+ assert.equal(closed.closed, false);
212
+ assert.equal(closed.reason, 'sitting_open');
209
213
  assert.equal(closed.item_id, '9');
214
+ assert.match(closed.next, /moves on by itself/);
210
215
  assert.deepEqual(w.opened.at(-1), { stageKey: s, taskIds: [String(100 + STAGES5.indexOf(s))] }, 'the stage\'s own task ids go to the gate');
211
216
  g = await gh.readGenesis(w.deps());
212
217
  assert.equal(g.stage, s, 'sitting is not passing: the stage holds until the vote');
@@ -355,3 +360,83 @@ test('the hall carries the five life plates byte-identical to the world\'s', ()
355
360
  }
356
361
  }
357
362
  });
363
+
364
+ // ── task 1005121: closing a stage is ONE call ────────────────────────────────
365
+
366
+ function oneCallWorld({ solo = true } = {}) {
367
+ const w = world();
368
+ const calls = [];
369
+ w.deps = ((base) => () => {
370
+ const d = base();
371
+ d.government = {
372
+ ...d.government,
373
+ closeGenesisStage: async ({ stageKey, taskIds, closedBy }) => {
374
+ calls.push({ stageKey, taskIds, closedBy });
375
+ if (!(w.tasks[stageKey] || []).every((t) => ['shipped', 'abandoned'].includes(t.status))) {
376
+ return { closed: false, reason: 'stage_tasks_unfinished', unfinished: [{ task_id: taskIds[0] }] };
377
+ }
378
+ if (solo) {
379
+ w.board[stageKey] = { state: 'passed', item_id: '9', waiting_on: [] };
380
+ return { closed: true, item: { id: 9 }, stage: { key: stageKey }, cast: { cast_via: 'stage_close', fresh: true } };
381
+ }
382
+ const status = {
383
+ key: stageKey, state: 'sitting', item_id: '9', pass_rule: 'consent', needs: 'Passes unless someone objects.',
384
+ voted: [], waiting_on: [{ builder_id: '1', display_name: 'Founder' }, { builder_id: '2', github_login: 'avery' }],
385
+ closes_at: 'T', remaining_seconds: 600,
386
+ };
387
+ w.board[stageKey] = status;
388
+ return { closed: false, item: { id: 9 }, stage: { key: stageKey }, opened: true, status, cast: null };
389
+ },
390
+ };
391
+ return d;
392
+ })(w.deps);
393
+ return { w, calls };
394
+ }
395
+
396
+ test('task 1005121: one call closes the stage and moves the project on, at every stage, the same way', async () => {
397
+ const { w, calls } = oneCallWorld();
398
+ await gh.startFounding(1, w.deps());
399
+ await gh.savePlan({}, 1, w.deps());
400
+ for (const s of STAGES5) {
401
+ assert.equal((await gh.closeStage(s, 1, w.deps())).reason, 'stage_tasks_unfinished');
402
+ w.finish(s);
403
+ const r = await gh.closeStage(s, 1, w.deps());
404
+ assert.equal(r.ok, true, `${s}: one call`);
405
+ assert.equal(r.closed, true);
406
+ assert.equal(r.stage, s);
407
+ assert.equal(r.confirm, 'stage_close', "the founder's confirm carried it, and the answer says how");
408
+ const next = s === 'legislation' ? null : STAGES5[STAGES5.indexOf(s) + 1];
409
+ assert.equal(r.next_stage, next, 'the answer names the next stage');
410
+ const g = await gh.readGenesis(w.deps());
411
+ assert.equal(g.stage, next || 'legislation', 'no second request: the project has already moved on');
412
+ }
413
+ assert.ok(calls.every((c) => String(c.closedBy) === '1'), 'the caller is who confirms');
414
+ assert.equal((await gh.readGenesis(w.deps())).can_come_alive, true, 'legislation closed: the alive door is open');
415
+ });
416
+
417
+ test('task 1005121: where a real sitting decides, the answer is not ok and says what it waits on and what to do', async () => {
418
+ const { w } = oneCallWorld({ solo: false });
419
+ await gh.startFounding(1, w.deps());
420
+ await gh.savePlan({}, 1, w.deps());
421
+ w.finish('scope');
422
+ const r = await gh.closeStage('scope', 1, w.deps());
423
+ assert.equal(r.ok, false, 'never ok: true for a stage that did not close');
424
+ assert.equal(r.closed, false);
425
+ assert.equal(r.reason, 'sitting_open');
426
+ assert.equal(r.item_id, '9');
427
+ assert.equal(r.waiting.pass_rule, 'consent');
428
+ assert.deepEqual(r.waiting.waiting_on.map((m) => m.builder_id), ['1', '2']);
429
+ assert.match(r.next, /Founder, avery/);
430
+ assert.ok(r.next.includes('Your vote is one of them: POST /api/bongos/government/board/items/9/votes'), r.next);
431
+ assert.equal((await gh.readGenesis(w.deps())).stage, 'scope', 'the stage holds until the sitting passes');
432
+ });
433
+
434
+ test('task 1005121: the close route answers 202 with the whole body for an open sitting, never a 200', () => {
435
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
436
+ const at = src.indexOf("router.post('/founding/stages/:stage/close'");
437
+ const route = src.slice(at, at + 700);
438
+ assert.ok(route.includes("r.reason === 'sitting_open') return res.status(202).json(r)"), 'an open sitting is a 202 with the whole body');
439
+ const hall = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
440
+ const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
441
+ assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
442
+ });
@@ -434,9 +434,9 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
434
434
  assert.match(html, /<main id="hall-main" class="sky-stage">/, 'the sky is drawn in the stage, the skip link\'s target');
435
435
  const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
436
436
  // criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
437
- assert.deepEqual(scripts(html).filter((s) => !['dom-utils.js', 'shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0])).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
437
+ assert.deepEqual(scripts(html).filter((s) => !['dom-utils.js', 'shell-game.js', 'shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0])).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
438
438
  // Both doors load the shell (tasks 1004327, 1004430); the SKY's files are the same.
439
- const skyScripts = (h) => scripts(h).filter((s) => !['dom-utils.js', 'shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0]));
439
+ const skyScripts = (h) => scripts(h).filter((s) => !['dom-utils.js', 'shell-game.js', 'shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0]));
440
440
  assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
441
441
  assert.equal(html.match(/thinking\.css\?v=([^"]+)/)[1], thinking.match(/thinking\.css\?v=([^"]+)/)[1]);
442
442
  assert.match(read('thinking.css'), /body\[data-page\] \.sky-stage \{/, 'the sheet dresses the one stage both doors draw in inside the hall');
@@ -303,3 +303,86 @@ test('the port carries the open and the status; the status route is pinned on th
303
303
  const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
304
304
  assert.match(pins, /'GET \/government\/board\/genesis-stages': 'perm:board\.vote\.cast'/);
305
305
  });
306
+
307
+ // ── task 1005121: closing a stage is ONE call ────────────────────────────────
308
+
309
+ const { closeGenesisStage } = require(path.join(GOV, 'genesis-close.js'));
310
+
311
+ test('task 1005121: under Monarchy / BDFL closeGenesisStage opens, confirms and CLOSES in one call, and records HOW', async () => {
312
+ const votes = [];
313
+ let karma = 0;
314
+ const passed = await capturing('board.genesis_stage.passed', () => withDb({
315
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
316
+ openBoardItem: async (args) => ({ ...stageItem(), constitution: MONARCHY, opened_by: args.openedBy }),
317
+ getBoardItemForVote: async () => stageItem(),
318
+ builderHoldsRankActive: async (id) => id === FOUNDER,
319
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder')],
320
+ getBoardItemAuthor: async (item) => item.opened_by,
321
+ insertBoardVote: async (row) => { const v = { ...row, voter_id: row.voterId, cast_via: row.castVia }; votes.push(v); return v; },
322
+ insertVoteKarma: async () => { karma += 1; },
323
+ getVotesForItem: async () => votes,
324
+ closeBoardItem: async ({ outcome }) => ({ ...stageItem(), closed_at: 'T', outcome }),
325
+ insertAuthorRatifyKarma: async () => true,
326
+ }, async () => {
327
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
328
+ assert.equal(out.closed, true, 'the answer says CLOSED — no second request needed');
329
+ assert.equal(out.stage.key, 'charter');
330
+ assert.equal(out.cast.cast_via, 'stage_close');
331
+ }));
332
+ assert.equal(votes.length, 1);
333
+ assert.equal(votes[0].castVia, 'stage_close', 'the vote row records it was a confirm given by closing the stage');
334
+ assert.equal(votes[0].direction, 'yes');
335
+ assert.equal(karma, 0, 'the confirm decided nothing the ask had not — no vote karma');
336
+ assert.equal(passed.length, 1, 'the genesis home hears the pass');
337
+ });
338
+
339
+ test('task 1005121: where one yes cannot carry it, nothing is cast and the answer says what the sitting waits on', async () => {
340
+ let inserted = 0;
341
+ await withDb({
342
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
343
+ openBoardItem: async () => ({ ...stageItem({ constitution: CONSENT }) }),
344
+ insertBoardVote: async () => { inserted += 1; return null; },
345
+ listLatestGenesisStageItems: async () => [stageItem({ constitution: CONSENT })],
346
+ getVotesForItems: async () => [],
347
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder'), member(AVERY, 'avery')],
348
+ }, async () => {
349
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
350
+ assert.equal(out.closed, false);
351
+ assert.equal(out.opened, true);
352
+ assert.equal(out.item.id, '90');
353
+ assert.equal(out.status.state, 'sitting');
354
+ assert.deepEqual(out.status.waiting_on.map((m) => m.builder_id), [FOUNDER, AVERY]);
355
+ assert.ok(out.status.needs, 'the rule, in words');
356
+ });
357
+ assert.equal(inserted, 0, 'no vote is cast on anyone\'s behalf under a rule one person cannot carry');
358
+ });
359
+
360
+ test('task 1005121: a refusal from the gate passes straight through, nothing opened', async () => {
361
+ await withDb({
362
+ getTaskStatuses: async () => [{ id: '101', status: 'active' }],
363
+ openBoardItem: async () => { throw new Error('must not be reached'); },
364
+ }, async () => {
365
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
366
+ assert.equal(out.closed, false);
367
+ assert.equal(out.reason, 'stage_tasks_unfinished');
368
+ });
369
+ });
370
+
371
+ test('task 1005121: cast_via stage_close is internal, genesis-only and yes-only; the vote route never passes it', async () => {
372
+ assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'proxy' })).reason, 'bad_cast_via');
373
+ await withDb({
374
+ getBoardItemForVote: async () => ({ ...stageItem(), subject_type: 'full_idea' }),
375
+ insertBoardVote: async () => { throw new Error('must not be reached'); },
376
+ }, async () => {
377
+ assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'stage_close' })).reason, 'bad_cast_via');
378
+ });
379
+ const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
380
+ const call = routes.slice(routes.indexOf('const out = await board.castVote({'), routes.indexOf('const out = await board.castVote({') + 300);
381
+ assert.doesNotMatch(call, /castVia|cast_via/, 'a request can never claim its vote was a stage-close confirm');
382
+ assert.ok(routes.includes("closeGenesisStage: require('../genesis-close').closeGenesisStage"), 'the one-call close is on the port');
383
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_038_vote_cast_via.sql'), 'utf8');
384
+ assert.match(sql, /cast_via text NOT NULL DEFAULT 'ballot'/, 'every vote before the column reads as a ballot');
385
+ assert.match(sql, /cast_via IN \('ballot', 'stage_close'\) AND \(cast_via = 'ballot' OR direction = 'yes'\)/);
386
+ const dbSrc = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
387
+ assert.match(dbSrc, /INSERT INTO government_board_votes \(item_id, voter_id, direction, reason_md, section_key, cast_via\)/);
388
+ });
@@ -54,6 +54,7 @@ const NON_GRANT_MIGRATIONS = new Set([
54
54
  'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
56
  'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
57
+ 'government_038_vote_cast_via.sql', // HOW a vote was cast: a ballot, or a stage-close confirm (task 1005121)
57
58
  ]);
58
59
 
59
60
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -289,7 +289,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
289
289
  // task 1004685 (the living city): gov-living.js, the model under the drawing, and
290
290
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
291
291
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
292
- assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
292
+ assert.deepEqual(scripts, ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, then the shared dialog (task 1005085), as on every hall page');
293
293
  // task 1004925: every script but dom-utils.js is deferred, so none holds up the
294
294
  // parser; defer runs them in document order, which is why the order above still
295
295
  // holds. dom-utils.js stays synchronous: the one-escaper guard
@@ -25,6 +25,8 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
25
25
  const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
26
26
  // task 1004492: the way back lives in shell-trail.js, which every page loads first.
27
27
  const TRAIL = fs.readFileSync(path.join(HALL, 'shell-trail.js'), 'utf8');
28
+ // task 1005142: game mode, the hall-wide half of the switch; every page loads it before the shell.
29
+ const GAME = fs.readFileSync(path.join(HALL, 'shell-game.js'), 'utf8');
28
30
  const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
29
31
 
30
32
  // Boot the shell once — one page load. `store` is the browser's localStorage and
@@ -125,11 +127,16 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
125
127
  };
126
128
  sandbox.globalThis = sandbox;
127
129
  vm.createContext(sandbox);
130
+ vm.runInContext(GAME, sandbox, { filename: 'shell-game.js' });
128
131
  vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
129
132
  vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
130
133
  const root = documentObj.documentElement;
131
134
  return {
132
135
  store,
136
+ game: () => windowObj.OTBShellGame,
137
+ gameStamp: () => root.dataset.game === 'on',
138
+ // The "how to leave" line: showing = the shell created it and it is not hidden.
139
+ hintShowing: () => els.has('fullscreen-hint') && els.get('fullscreen-hint').hidden === false,
133
140
  button: () => getElementById('fullscreen-toggle'),
134
141
  on: () => root.dataset.fullscreen === 'on',
135
142
  plain: () => root.dataset.fullscreenPlain === 'on',
@@ -477,3 +484,97 @@ test('source: full screen hides the sidebar and foot, and collapses the top bar
477
484
  assert.match(css, /html\[data-fullscreen="on"\] \.topbar-side > :not\(\.fullscreen-toggle\)/);
478
485
  assert.doesNotMatch(css, /html\[data-fullscreen="on"\] \.app-topbar \{ display: none/);
479
486
  });
487
+
488
+ // GAME MODE (task 1005142, BONGOS-V3 R02). The spec's done-when: no page shows the
489
+ // sidebar in game mode. Game mode is hall-wide — one key, read by the SAME switch
490
+ // — so these drive the real shell-game.js + shell.js the way a reader moves
491
+ // through the hall: page after page over one browser store.
492
+ test('game mode: EVERY page opens without its bars — plain, laid-out, and one that remembers "off"', () => {
493
+ const store = { 'hall-game': 'on', 'hall-fullscreen:goals': 'off' };
494
+ for (const [page, optIn] of [['goals', false], ['work', false], ['thinking', true], ['city', true]]) {
495
+ const p = boot({ page, optIn, store });
496
+ assert.equal(p.on(), true, `${page} opens in full screen`);
497
+ assert.equal(p.gameStamp(), true, `${page}: html[data-game="on"]`);
498
+ assert.equal(p.plain(), optIn === false, `${page}: the plain mark still follows the page`);
499
+ }
500
+ assert.equal(boot({ page: 'goals', optIn: false, store: {} }).on(), false, 'and outside game mode nothing changed');
501
+ });
502
+
503
+ test('game mode: every hall page carries the switch — none opts out, and each loads shell-game.js before the shell', () => {
504
+ const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html'));
505
+ assert.ok(pages.length >= 30, `found ${pages.length} hall pages`);
506
+ for (const f of pages) {
507
+ const html = read(f);
508
+ assert.doesNotMatch(html, /data-fullscreen-switch="off"/, `${f} must not opt out: it would keep its sidebar in game mode`);
509
+ assert.match(html, /id="app-topbar"/, `${f}: the top bar is where the shell renders the switch`);
510
+ const tags = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?><\/script>/g)].map((m) => [m[1], !!m[2]]);
511
+ const game = tags.findIndex((t) => t[0] === 'shell-game');
512
+ const shell = tags.findIndex((t) => t[0] === 'shell');
513
+ assert.ok(game >= 0 && game < shell, `${f} loads shell-game.js before shell.js`);
514
+ assert.equal(tags[game][1], tags[shell][1], `${f}: both load the same way, so the order holds`);
515
+ }
516
+ });
517
+
518
+ test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
519
+ const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
520
+ const goals = boot({ page: 'goals', optIn: false, store });
521
+ goals.press('Escape');
522
+ assert.equal(goals.on(), false, 'Escape still leaves');
523
+ assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
524
+ assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
525
+ const sky = boot({ page: 'thinking', store });
526
+ sky.button().click();
527
+ assert.equal(sky.on(), false, 'the corner button leaves too');
528
+ assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
529
+ });
530
+
531
+ test('game mode: the Escape hand-off is unchanged — a press the page used, or one in a field, does not leave', () => {
532
+ const p = boot({ page: 'goals', optIn: false, store: { 'hall-game': 'on' } });
533
+ p.press('Escape', { pageUses: true });
534
+ assert.equal(p.on(), true, 'a page listener that called preventDefault keeps the press');
535
+ p.press('Escape', { target: { tagName: 'TEXTAREA' } });
536
+ assert.equal(p.on(), true);
537
+ });
538
+
539
+ test('game mode: set() switches the page live, and turning it off hands each page back its own memory', () => {
540
+ const store = { 'hall-fullscreen:thinking': 'on' };
541
+ const goals = boot({ page: 'goals', optIn: false, store });
542
+ goals.game().set(true);
543
+ assert.equal(store['hall-game'], 'on');
544
+ assert.equal(goals.on(), true, 'on at once, no reload');
545
+ assert.ok(goals.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
546
+ goals.game().set(false);
547
+ assert.equal(goals.on(), false, 'goals remembered nothing, so its bars return');
548
+ assert.equal(goals.gameStamp(), false);
549
+ assert.equal(store['hall-fullscreen:goals'], undefined, 'and switching the mode wrote nothing for the page');
550
+ const sky = boot({ page: 'thinking', store: Object.assign(store, { 'hall-game': 'on' }) });
551
+ sky.game().set(false);
552
+ assert.equal(sky.on(), true, 'the sky had chosen full screen itself, and keeps it');
553
+ });
554
+
555
+ test('game mode: ?game=on / ?game=off set it from the URL; anything else is ignored', () => {
556
+ const store = {};
557
+ assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=on' }).on(), true);
558
+ assert.equal(store['hall-game'], 'on');
559
+ assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'and it holds on the next page');
560
+ assert.equal(boot({ page: 'work', optIn: false, store, search: '?game=off' }).on(), false);
561
+ boot({ page: 'work', optIn: false, store, search: '?game=%22%3E%3Cimg%3E' });
562
+ assert.equal(store['hall-game'], 'off', 'a value that is not on/off is not stored');
563
+ });
564
+
565
+ test('game mode: how to leave is said once per tab, not on every page; outside it the shell\'s rule stands', () => {
566
+ const store = { 'hall-game': 'on' };
567
+ const session = {};
568
+ assert.equal(boot({ page: 'goals', optIn: false, store, session }).hintShowing(), true, 'the first page says it');
569
+ assert.equal(boot({ page: 'work', optIn: false, store, session }).hintShowing(), false, 'the next does not repeat it');
570
+ const own = { 'hall-fullscreen:thinking': 'on' };
571
+ assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true);
572
+ assert.equal(boot({ page: 'thinking', store: own, session: {} }).hintShowing(), true, 'a remembered page says it on every open, as before');
573
+ });
574
+
575
+ test('game mode: storage that throws leaves the hall out of game mode, with the switch working', () => {
576
+ const p = boot({ page: 'goals', optIn: false, storageThrows: true, search: '?game=on' });
577
+ assert.equal(p.on(), false);
578
+ p.button().click();
579
+ assert.equal(p.on(), true);
580
+ });
@@ -443,7 +443,7 @@ test('PAGE: in the hall family, with no prototype key', () => {
443
443
  assert.deepEqual(sheets, ['style.css', 'oversight.css', 'speciality-tools.css', 'my-agents.css'],
444
444
  'rides the oversight archetype; the shared speciality sheet (task 1005052) before its own, so the page skin wins');
445
445
  const scripts = [...html.matchAll(/src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
446
- assert.deepEqual(scripts.slice(0, 4), ['dom-utils', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
446
+ assert.deepEqual(scripts.slice(0, 5), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell']); // dom-utils first: the shell escapes through it (task 1004876)
447
447
  for (const s of ['gov-living', 'gov-positions', 'my-agents', 'speciality-tools']) assert.ok(scripts.includes(s), `loads ${s}.js`);
448
448
  assert.doesNotMatch(html, /ma-key|data-proto|Prototype data/, 'nothing on the page is prototype data, so there is no key');
449
449
  assert.doesNotMatch(read('my-agents.js'), /governor-overlay/, 'and it reads no overlay');
@@ -490,7 +490,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
490
490
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
491
491
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
492
492
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
493
- assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
493
+ assert.deepEqual(scripts.filter((s) => !/^street-(deck|office)$/.test(s)), ['dom-utils', 'shell-game', 'shell-trail', 'craft-mode', 'shell', 'hall-dialog', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-depth', 'street-desk', 'street-amend', 'street'], 'the hall shell (task 1004544), the living model and its one reader (task 1004685: city-draw.js counts with it, so it loads first), the shared kit, then the card (task 1004528), the interiors (task 1004526), the living street\'s exterior (street-depth.js), the floor desk (task 1004558) and the drafted amendment (task 1004530); the deck rides beside them');
494
494
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
495
495
  });
496
496
 
@@ -102,7 +102,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
102
102
  // task 1005051: Roadmap left it (folded into Goals; /roadmap redirects).
103
103
  // task 1005070: the remembered nav (the rail paints before /me).
104
104
  // task 1005088: the sidebar mark is drawn by reference to the one brand mark.
105
- const SHELL_SHA = '2845bfedfaf7846f';
105
+ const SHELL_SHA = 'e9c13c142bcd2a95';
106
106
 
107
107
  test('shell.js has not changed without its cache-bust being bumped', () => {
108
108
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);