@bongos/core 1.25.22 → 1.25.24

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.
@@ -209,6 +209,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardR
209
209
  // What this stage left behind (task 1005115): null where the store is not read.
210
210
  document: documents[key] || null,
211
211
  all_finished: allFinished,
212
+ // task 1005107: a stage the Board Room passed is fixed; its answers read-only, with why.
213
+ locked: b && b.state === 'passed' ? { reason: require('./genesis-work').lockReason(key) } : null,
212
214
  board: b ? {
213
215
  state: b.state, item_id: b.item_id || null, needs: b.needs || null,
214
216
  one_vote_decides: !!b.one_vote_decides, waiting_on: b.waiting_on || [],
@@ -233,6 +235,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardR
233
235
  day: mode.day,
234
236
  deciding: mode.stage === 'decide',
235
237
  plan: plan || null,
238
+ // The plan stays editable for as long as genesis is on (task 1005107): POST /founding/plan revises it.
239
+ plan_editable: !!plan,
236
240
  // The demo's time box when this genesis is a demo's (task 1004506), else null.
237
241
  demo: plan && plan.demo ? plan.demo : null,
238
242
  stages,
@@ -257,7 +261,7 @@ function seams(deps = {}) {
257
261
  government: deps.government !== undefined ? deps.government : api.resolveOptional('government'),
258
262
  // Each stage's document summary (task 1005115): the founder's store unless injected.
259
263
  // Fails soft in readGenesis, so a hall without the tables still renders.
260
- documents: deps.documents !== undefined ? deps.documents : () => require('./genesis-work').readDocuments(),
264
+ documents: deps.documents !== undefined ? deps.documents : (extra) => require('./genesis-work').readDocuments(extra),
261
265
  now: deps.now,
262
266
  };
263
267
  }
@@ -317,7 +321,10 @@ async function readGenesis(deps = {}) {
317
321
  }
318
322
  let documents = {};
319
323
  if (typeof s.documents === 'function') {
320
- try { documents = (await s.documents()) || {}; } catch (_) { documents = {}; } // the home still renders without them
324
+ // The board was just read: hand its passed stages over, so the documents need no second read.
325
+ const locks = {};
326
+ for (const [k, row] of Object.entries(board)) if (row && row.state === 'passed') locks[k] = { reason: require('./genesis-work').lockReason(k) };
327
+ try { documents = (await s.documents({ locks })) || {}; } catch (_) { documents = {}; } // the home still renders without them
321
328
  }
322
329
  return shapeGenesis({ mode, plan, tasks, board, stageAt, boardRoom: hasBoardRoom(s.government), documents });
323
330
  }
@@ -371,12 +378,13 @@ async function setEnabled(enabled, builderId, deps = {}) {
371
378
  }
372
379
 
373
380
  // savePlan — the decide stage's door: record the plan, file the five stages as tasks,
374
- // and move to scope. Only while deciding.
381
+ // and move to scope. After that, the same door REVISES the plan (task 1005107; owner:
382
+ // "the UI is very restricting to be in the flow and not be able to go back and edit").
375
383
  async function savePlan(body, builderId, deps = {}) {
376
384
  const s = seams(deps);
377
385
  const mode = await fm.foundingModeFor(modeDeps(s));
378
386
  if (!mode) return { ok: false, reason: 'not_founding' };
379
- if (mode.stage !== 'decide') return { ok: false, reason: 'not_deciding' };
387
+ if (mode.stage !== 'decide') return revisePlan(body, builderId, s);
380
388
  const parsed = parsePlan(body);
381
389
  if (!parsed.ok) return parsed;
382
390
  if (!s.lifecycle || typeof s.lifecycle.seedGenesis !== 'function') return { ok: false, reason: 'no_board' };
@@ -387,6 +395,58 @@ async function savePlan(body, builderId, deps = {}) {
387
395
  return { ok: true, plan: parsed.plan, seeded: seeded && seeded.seeded, charter };
388
396
  }
389
397
 
398
+ // revisePlan — change the settled plan while genesis is on. The decide stage is never
399
+ // put to a sitting, so it is never fixed. Re-sending the same plan changes NOTHING (no
400
+ // write, no side effect). A changed plan is saved and what was derived from it follows:
401
+ // days each stage's due date is computed from the plan at read time, so it moves at once
402
+ // government a different form is filed as a charter amendment, decided like any (applyGovernmentChoice)
403
+ // existing turning it on files the gather-what-exists step (the seeder dedups); turning it
404
+ // off cannot take back a step already filed, and the answer says so
405
+ // `could_not` lists, in words, every change it could not carry through. A demo's settled
406
+ // time box and minutes are kept: the plan route does not carry them.
407
+ async function revisePlan(body, builderId, s) {
408
+ const prevRow = await s.get(PLAN_KEY);
409
+ const prev = readPlanValue(prevRow && prevRow.value);
410
+ const parsed = parsePlan(body);
411
+ if (!parsed.ok) return parsed;
412
+ const plan = parsed.plan;
413
+ if (prev && prev.demo) { plan.demo = prev.demo; if (prev.minutes) plan.minutes = prev.minutes; }
414
+ const same = prev && JSON.stringify(canonPlan(prev)) === JSON.stringify(canonPlan(plan));
415
+ if (same) return { ok: true, revised: false, changed: [], could_not: [], plan: prev };
416
+ const changed = prev ? Object.keys(plan).filter((k) => JSON.stringify(prev[k]) !== JSON.stringify(plan[k])) : Object.keys(plan);
417
+ const couldNot = [];
418
+ await s.set(PLAN_KEY, JSON.stringify(plan), builderId);
419
+ let charter = null;
420
+ if (changed.includes('government')) {
421
+ charter = plan.government ? await applyGovernmentChoice(plan.government, builderId, s) : { state: 'none' };
422
+ if (charter.state === 'not_filed' || charter.state === 'needs_council') {
423
+ couldNot.push(`The government could not be changed to ${plan.government} here: ${GOV_NOT_FILED[charter.reason || charter.state] || GOV_NOT_FILED.charter_failed}. The one in force stays.`);
424
+ }
425
+ }
426
+ if (changed.includes('existing')) {
427
+ if (plan.existing && s.lifecycle && typeof s.lifecycle.seedGenesis === 'function') {
428
+ await s.lifecycle.seedGenesis({ builderId, existing: true });
429
+ } else if (!plan.existing) {
430
+ couldNot.push('The step for gathering what already exists stays on the board: a filed task is not taken back. Drop it there if it no longer applies.');
431
+ }
432
+ }
433
+ return { ok: true, revised: true, changed, could_not: couldNot, plan, charter };
434
+ }
435
+
436
+ // Why a government choice could not be filed, as a person would say it.
437
+ const GOV_NOT_FILED = Object.freeze({
438
+ needs_council: 'it needs a council with a rank before it can seat anyone',
439
+ no_government: 'this project has no Board Room to file it with',
440
+ charter_failed: 'the change to the charter could not be filed',
441
+ });
442
+
443
+ // canonPlan — PURE. A plan with its keys in one order, so two equal plans compare equal.
444
+ function canonPlan(p) {
445
+ const out = {};
446
+ for (const k of Object.keys(p).sort()) out[k] = p[k] && typeof p[k] === 'object' && !Array.isArray(p[k]) ? canonPlan(p[k]) : p[k];
447
+ return out;
448
+ }
449
+
390
450
  // applyGovernmentChoice — the government picked at decide becomes the one in force
391
451
  // (task 1004656). It is filed as an ordinary charter amendment under the constitution
392
452
  // in force (the government port's proposeCharter), so the board decides it like any
@@ -297,9 +297,9 @@ function documentOf(s) {
297
297
  markdown: filled ? lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' : '',
298
298
  counts: s.counts,
299
299
  updated_at: latest([s.updated_at, ...said.map((q) => q.answer.updated_at)]),
300
- // Editing never reopens the stage. Task 1005107 narrows this for a stage the Board
301
- // Room passed; until then every document is open to its author.
302
- editable: true,
300
+ // Editing never reopens the stage. A stage the Board Room passed is fixed (task 1005107).
301
+ editable: !s.locked,
302
+ locked_reason: s.locked ? s.locked.reason : null,
303
303
  edit: {
304
304
  answer: `PUT /api/bongos/founding/work/${s.stage}/answers/:question`,
305
305
  page: `PUT /api/bongos/founding/work/${s.stage}/page`,
@@ -320,9 +320,11 @@ async function readDocument(stage, deps = {}) {
320
320
  // genesis home carries per stage, so the page shows each stage's document in place.
321
321
  async function readDocuments(deps = {}) {
322
322
  const out = {};
323
+ let locks = {};
324
+ try { locks = await locksFor(deps); } catch (_) { locks = {}; }
323
325
  for (const s of await readAll(deps)) {
324
- const d = documentOf(s);
325
- out[s.stage] = { title: d.title, filled: d.filled, updated_at: d.updated_at, editable: d.editable };
326
+ const d = documentOf({ ...s, locked: locks[s.stage] || null });
327
+ out[s.stage] = { title: d.title, filled: d.filled, updated_at: d.updated_at, editable: d.editable, locked_reason: d.locked_reason };
326
328
  }
327
329
  return out;
328
330
  }
@@ -441,10 +443,50 @@ async function readAll(deps = {}) {
441
443
  return shapeAll(await storeOf(deps).loadAll());
442
444
  }
443
445
 
446
+ // ── which stages are fixed (task 1005107) ─────────────────────────────────────
447
+ // The owner's smallest honest answer: any stage not yet closed is editable; a stage the
448
+ // Board Room PASSED is shown read-only with a plain reason, because its answers are what
449
+ // the team voted on. A stage that moved on without a Board Room (a solo founder's
450
+ // finished tasks, task 1005115) stays editable: nobody voted on it. No government module
451
+ // means no sitting, so nothing is fixed. If the Board Room cannot be read, writes are
452
+ // refused rather than risk changing what was voted on.
453
+ const lockReason = (stage) => `The Board Room passed the ${stage} stage, so its answers are fixed: they are what was voted on. To change them, put the change to the Board Room.`;
454
+
455
+ // locksFor(deps) → { stage: { reason } } for every stage the Board Room passed. A caller
456
+ // that has already read the board passes the answer in as deps.locks (one read per request).
457
+ async function locksFor(deps = {}) {
458
+ if (deps.locks) return deps.locks;
459
+ const api = deps.api || require('../../src/module-api');
460
+ const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
461
+ if (!gov || typeof gov.genesisStageStatus !== 'function') return {};
462
+ const st = await gov.genesisStageStatus();
463
+ const out = {};
464
+ for (const row of (st && st.stages) || []) {
465
+ if (row && row.state === 'passed' && isStage(row.key)) out[row.key] = { reason: lockReason(row.key) };
466
+ }
467
+ return out;
468
+ }
469
+
470
+ // guardWrite(stage, deps) → { refused } when the stage may not be written, else
471
+ // { deps } carrying the lock read, so the read that answers the write reuses it. Every
472
+ // write checks this first, before its own content checks, so a fixed stage always
473
+ // answers stage_closed whatever was sent.
474
+ async function guardWrite(stage, deps) {
475
+ let locks;
476
+ try { locks = await locksFor(deps); } catch (_) {
477
+ return { refused: { ok: false, reason: 'stage_lock_unknown', message: 'The Board Room could not be read, so nothing was changed. Try again in a moment.' } };
478
+ }
479
+ if (locks[stage]) return { refused: { ok: false, reason: 'stage_closed', message: locks[stage].reason } };
480
+ return { deps: { ...deps, locks } };
481
+ }
482
+
444
483
  async function readStage(stage, deps = {}) {
445
484
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
446
485
  const all = await readAll(deps);
447
486
  const work = all.find((s) => s.stage === stage);
487
+ let locks = {};
488
+ try { locks = await locksFor(deps); } catch (_) { locks = {}; }
489
+ work.locked = locks[stage] || null;
448
490
  if (stage === 'charter') work.advice = await charterAdvice(work, deps);
449
491
  return { ok: true, work };
450
492
  }
@@ -490,16 +532,22 @@ async function setDoor(stage, door, by, deps = {}) {
490
532
  return readStage(stage, deps);
491
533
  }
492
534
 
493
- async function setPage(stage, page, by, deps = {}) {
535
+ async function setPage(stage, page, by, deps0 = {}) {
494
536
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
537
+ const g = await guardWrite(stage, deps0);
538
+ if (g.refused) return g.refused;
539
+ const deps = g.deps;
495
540
  const words = text(page);
496
541
  if (words.length > LIMITS.page) return { ok: false, reason: 'page_too_long' };
497
542
  await storeOf(deps).upsertWork(stage, { page_md: words || null }, by);
498
543
  return readStage(stage, deps);
499
544
  }
500
545
 
501
- async function setLink(stage, url, by, deps = {}) {
546
+ async function setLink(stage, url, by, deps0 = {}) {
502
547
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
548
+ const g = await guardWrite(stage, deps0);
549
+ if (g.refused) return g.refused;
550
+ const deps = g.deps;
503
551
  const c = checkLink(url);
504
552
  if (!c.ok) return c;
505
553
  await storeOf(deps).upsertWork(stage, { link_url: c.value }, by);
@@ -508,7 +556,11 @@ async function setLink(stage, url, by, deps = {}) {
508
556
 
509
557
  // setAnswer — store or revise one answer. opts.ifEmpty (for carrying answers over,
510
558
  // task 1005274) writes only where the founder has said nothing yet.
511
- async function setAnswer(stage, questionId, body, by, deps = {}, opts = {}) {
559
+ async function setAnswer(stage, questionId, body, by, deps0 = {}, opts = {}) {
560
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
561
+ const g = await guardWrite(stage, deps0);
562
+ if (g.refused) return g.refused;
563
+ const deps = g.deps;
512
564
  const c = checkAnswer(stage, questionId, body);
513
565
  if (!c.ok) return c;
514
566
  const { changed } = await storeOf(deps).writeAnswer(stage, questionId, c.value, by, opts);
@@ -517,8 +569,11 @@ async function setAnswer(stage, questionId, body, by, deps = {}, opts = {}) {
517
569
  }
518
570
 
519
571
  // clearAnswer — un-answer: the question goes back to empty (not to "skipped").
520
- async function clearAnswer(stage, questionId, by, deps = {}) {
572
+ async function clearAnswer(stage, questionId, by, deps0 = {}) {
521
573
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
574
+ const g = await guardWrite(stage, deps0);
575
+ if (g.refused) return g.refused;
576
+ const deps = g.deps;
522
577
  if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
523
578
  const { cleared } = await storeOf(deps).clearAnswer(stage, questionId, by);
524
579
  const r = await readStage(stage, deps);
@@ -547,6 +602,6 @@ async function readContext(deps = {}) {
547
602
  // and are exercised through the operations (tests/genesis_work.mjs).
548
603
  module.exports = {
549
604
  STAGES, LIMITS, QUESTIONS, assistPrompt,
550
- readDocument, readDocuments,
605
+ readDocument, readDocuments, lockReason,
551
606
  pgStore, readAll, readStage, setDoor, setPage, setLink, setAnswer, clearAnswer, answerHistory, readContext,
552
607
  };
@@ -17,6 +17,7 @@ const handoff = require('../guide-handoff'); // the start-a-project guide's an
17
17
 
18
18
  const REFUSALS = {
19
19
  unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
20
+ stage_closed: 409, stage_lock_unknown: 503,
20
21
  unknown_fact: 400, bad_fact: 400, region_not_in_country: 400,
21
22
  empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
22
23
  };
@@ -28,7 +29,7 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
28
29
  try {
29
30
  const r = await fn(req);
30
31
  if (r && r.ok === false) {
31
- return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
32
+ return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.message || r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
32
33
  }
33
34
  res.set('Cache-Control', 'no-store');
34
35
  res.json(r);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.22",
3
+ "version": "1.25.24",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.22",
9
+ "version": "1.25.24",
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.25.22",
3
+ "version": "1.25.24",
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",
@@ -11900,5 +11900,17 @@
11900
11900
  "id": "1005129",
11901
11901
  "text": "Choosing how contributors are rewarded now shows, for every option, what the platform really requires and what is closed — and the project now remembers where it is and where its builders are, so those notes fit it."
11902
11902
  }
11903
+ ],
11904
+ "1.25.23": [
11905
+ {
11906
+ "id": "1005285",
11907
+ "text": "In the builders' hall, a project still being founded no longer lists its closed rooms in the quick-jump search; only the rooms that are open can be found."
11908
+ }
11909
+ ],
11910
+ "1.25.24": [
11911
+ {
11912
+ "id": "1005107",
11913
+ "text": "A founder can now go back and change their Genesis plan after saving it, and change any earlier stage's answers until the Board Room has voted that stage through — a voted stage shows its answers with a plain reason why they a"
11914
+ }
11903
11915
  ]
11904
11916
  }
@@ -24,6 +24,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
24
24
  | `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
25
25
  | `--signed-out` | answer `/me` with a 401 — preview the signed-out sign-in card |
26
26
  | `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
27
+ | `--founding scope` | preview a project being founded, at that genesis stage (task 1005285): `/me.founding_mode` is the real `shapeFoundingMode`, `GET /founding/genesis` the real `shapeGenesis` over `fixtures/founding__genesis__tasks.json`, and a sleeping room's page is refused by the real `foundingPageGate` |
27
28
 
28
29
  Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
29
30
  the canned writes in `server.js` and, since task 1004685, the five per-builder pref
@@ -74,6 +74,12 @@ const SIGNED_OUT = process.argv.includes('--signed-out');
74
74
  // fixtures answer in a millisecond, which is why no loader had ever been seen.
75
75
  const LATENCY = Math.max(0, Number(arg('--latency')) || 0);
76
76
  const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
77
+ // --founding <stage>: preview a project being founded, at that genesis stage (task 1005285).
78
+ // The answers are the server's own code, never a hand-written copy: /me.founding_mode is
79
+ // shapeFoundingMode (modules/onboarding/founding-mode.js), GET /founding/genesis is
80
+ // shapeGenesis over fixtures/founding__genesis__tasks.json (modules/onboarding/genesis-home.js),
81
+ // and a sleeping room's page is refused by the real foundingPageGate (src/bongos/).
82
+ const FOUNDING_STAGE = arg('--founding');
77
83
  process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
78
84
 
79
85
  /* eslint-disable import/no-dynamic-require */
@@ -93,6 +99,18 @@ const { overrideKey, resolveLiveIdentity } = require('./live-identity');
93
99
  const { createPrefRecorder, readJsonBody } = require('./pref-writes');
94
100
  const PREFS = createPrefRecorder();
95
101
 
102
+ let FOUNDING = null;
103
+ let GENESIS = null;
104
+ if (FOUNDING_STAGE) {
105
+ const fm = require(path.join(ROOT, 'modules', 'onboarding', 'founding-mode.js'));
106
+ const gh = require(path.join(ROOT, 'modules', 'onboarding', 'genesis-home.js'));
107
+ FOUNDING = fm.shapeFoundingMode({ active: 'true', stage: FOUNDING_STAGE, startedAt: new Date(Date.now() - 2 * 86400000).toISOString() });
108
+ if (!FOUNDING) throw new Error(`--founding: not a genesis stage: ${FOUNDING_STAGE} (one of ${fm.STAGES.join(', ')})`);
109
+ const tasks = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'founding__genesis__tasks.json'), 'utf8'));
110
+ delete tasks._comment;
111
+ GENESIS = gh.shapeGenesis({ mode: FOUNDING, plan: gh.parsePlan({}).plan, tasks });
112
+ }
113
+
96
114
  // The session is the ONE thing this harness reads from outside its own tree, and
97
115
  // it reads it through the FAIL-OPEN helper on purpose: apiCall/cliClient exit the
98
116
  // process on a 401, which would kill a preview over a cosmetic feature.
@@ -423,12 +441,17 @@ app.use((req, res, next) => {
423
441
  // wasn't resolved live falls through to its fixture exactly as before.
424
442
  const q = req.query || {};
425
443
  const live = liveOverrides.get(overrideKey(apiPath, q)) ?? liveOverrides.get(apiPath);
444
+ if (FOUNDING && apiPath === 'founding/genesis') {
445
+ const me = liveOverrides.get('me') ?? fixtureFor('me');
446
+ return res.json({ genesis: GENESIS, viewer_id: me && me.builder ? me.builder.id : null });
447
+ }
426
448
  const fix = live !== undefined ? live : fixtureFor(apiPath, q);
427
449
  if (fix === undefined) {
428
450
  console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
429
451
  return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
430
452
  }
431
453
  let payload = EMPTY ? emptied(fix) : fix;
454
+ if (FOUNDING && apiPath === 'me') payload = { ...payload, founding_mode: FOUNDING };
432
455
  if (RANK_OVERRIDE && apiPath === 'me') {
433
456
  payload = JSON.parse(JSON.stringify(payload));
434
457
  if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
@@ -486,8 +509,15 @@ app.get('/', (req, res, next) => { if (!sendPage(res, 'index.html')) next(); });
486
509
  // fell through to the real gate and 302'd to sign-in (task 1003305).
487
510
  app.get(/^\/task\/\d+$/, (req, res, next) => { if (!sendPage(res, 'task.html')) next(); });
488
511
  app.get(/^\/idea\/\d+$/, (req, res, next) => { if (!sendPage(res, 'idea.html')) next(); });
512
+ // --founding: a sleeping room's page answers as prod does, through the real gate (it
513
+ // matches the apex /builders/<room> shape; the harness serves pages at the root).
514
+ const foundingGate = FOUNDING
515
+ ? require(path.join(ROOT, 'src', 'bongos', 'founding-page-gate.js')).foundingPageGate({ isBuildersHost: () => true, read: async () => FOUNDING })
516
+ : null;
489
517
  app.get(/^\/([a-z-]+)$/, (req, res, next) => {
490
- if (!sendPage(res, `${req.params[0]}.html`)) next();
518
+ const serve = () => { if (!sendPage(res, `${req.params[0]}.html`)) next(); };
519
+ if (!foundingGate) return serve();
520
+ return foundingGate({ path: `/builders${req.path}` }, res, serve);
491
521
  });
492
522
 
493
523
  // What the READING ROOM reads (task 1003306): the primer document and
@@ -554,6 +584,7 @@ function accountLine() {
554
584
  app.listen(PORT, '127.0.0.1', () => {
555
585
  console.log(`hall-preview: http://builders.localhost:${PORT}/`);
556
586
  console.log(accountLine());
587
+ if (FOUNDING) console.log(` founding: stage ${FOUNDING.stage}; asleep: ${Object.keys(FOUNDING.asleep).join(', ') || 'none'}`);
557
588
  console.log(` brand: ${BRAND_FILE}${EMPTY ? ' (EMPTY fixtures)' : ''}${MODULES_OFF.length ? ` modules off: ${MODULES_OFF.join(',')}` : ''}`);
558
589
  console.log(' pages:', Object.keys(stamped).map((f) => f.replace(/\.html$/, '')).sort().join(' '));
559
590
  });
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.25.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.24'; // 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');
@@ -229,7 +229,7 @@ function world({ withGovernment = true } = {}) {
229
229
  lifecycle, government, now, documents: null,
230
230
  });
231
231
  return {
232
- store, tasks, board, opened, deps,
232
+ store, tasks, board, opened, deps, government,
233
233
  finish: (s) => tasks[s].forEach((t) => { t.status = 'shipped'; }),
234
234
  pass: (s) => { board[s] = { state: 'passed', item_id: '9', waiting_on: [] }; },
235
235
  tick: (days) => { now = new Date(now.getTime() + days * 86400000); },
@@ -251,7 +251,7 @@ test('decide → five stages through the Board Room → alive, and the hall open
251
251
  g = await gh.readGenesis(w.deps());
252
252
  assert.equal(g.stage, 'scope');
253
253
  assert.equal(g.can_close, false, 'a stage with unfinished tasks cannot be put to the board');
254
- assert.equal((await gh.savePlan({}, 1, w.deps())).reason, 'not_deciding', 'the plan is settled once');
254
+ assert.equal((await gh.savePlan({}, 1, w.deps())).revised, false, 'the same plan again changes nothing (task 1005107)');
255
255
 
256
256
  for (const s of STAGES5) {
257
257
  assert.equal((await gh.readGenesis(w.deps())).stage, s);
@@ -446,6 +446,48 @@ test('task 1005115: each stage on the genesis home carries its document', async
446
446
  assert.equal(g.stages[0].document, null);
447
447
  });
448
448
 
449
+ test('task 1005107: the plan can be changed after decide; the same plan again changes nothing', async () => {
450
+ const w = world();
451
+ w.government.proposeCharter = async ({ form }) => ({ ok: true, item: { id: 44, constitution: { pass_rule: 'first_ratifier' } }, reads_as: { name: form } });
452
+ await gh.startFounding(1, w.deps());
453
+ await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
454
+ let g = await gh.readGenesis(w.deps());
455
+ assert.equal(g.plan_editable, true);
456
+ const before = new Map(w.store);
457
+ const same = await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
458
+ assert.equal(same.ok, true);
459
+ assert.equal(same.revised, false);
460
+ assert.deepEqual([...w.store], [...before], 'leaving it unchanged writes nothing');
461
+ // A changed length moves the stage's due date at once.
462
+ const r = await gh.savePlan({ days: { scope: 10 } }, 1, w.deps());
463
+ assert.equal(r.revised, true);
464
+ assert.deepEqual(r.changed, ['days']);
465
+ g = await gh.readGenesis(w.deps());
466
+ assert.equal(g.stages[0].days, 10);
467
+ assert.equal(g.stage, 'scope', 'revising the plan never moves the stage');
468
+ // A different government is filed for the board, like any charter change.
469
+ const gov = await gh.savePlan({ days: { scope: 10 }, government: 'republic' }, 1, w.deps());
470
+ assert.equal(gov.charter.state, 'sitting');
471
+ assert.equal(gov.charter.item_id, '44');
472
+ // Turning "existing" off cannot take back a filed step, and it says so.
473
+ await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: true }, 1, w.deps());
474
+ const off = await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: false }, 1, w.deps());
475
+ assert.equal(off.could_not.length, 1);
476
+ assert.match(off.could_not[0], /stays on the board/);
477
+ assert.equal((await gh.savePlan({ days: { scope: 999 } }, 1, w.deps())).reason, 'bad_days', 'a revision is held to the same ranges');
478
+ });
479
+
480
+ test('task 1005107: a stage the Board Room passed is shown fixed, with why; one that has not is not', async () => {
481
+ const w = world();
482
+ await gh.startFounding(1, w.deps());
483
+ await gh.savePlan({}, 1, w.deps());
484
+ w.finish('scope');
485
+ w.pass('scope');
486
+ const g = await gh.readGenesis(w.deps());
487
+ assert.match(g.stages[0].locked.reason, /Board Room passed the scope stage/);
488
+ assert.equal(g.stages[1].locked, null);
489
+ });
490
+
449
491
  test('the view: states, wakes, the life plates and a due date from the plan', () => {
450
492
  const mode = { stage: 'strategize', stage_number: 2, stage_count: 5, day: 9 };
451
493
  const g = gh.shapeGenesis({
@@ -704,6 +746,77 @@ test('task 1005115: with no Board Room the card says the stage moved by itself,
704
746
  assert.doesNotMatch(host.innerHTML, /The strategy<\/b>/, 'an empty document is not listed');
705
747
  });
706
748
 
749
+ test('task 1005107: the card offers to change the plan, reopens it with the saved answers, and says which stages are fixed', async () => {
750
+ const genesis = gh.shapeGenesis({
751
+ mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
752
+ plan: { days: { scope: 7, strategize: 9, charter: 4, legalize: 7, legislation: 3 }, passes: 2, advisors: 'cbaas', existing: true, government: null, expected_builders: null },
753
+ tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
754
+ board: { scope: { state: 'passed', waiting_on: [] } },
755
+ stageAt: { strategize: '2026-10-08T00:00:00Z' },
756
+ });
757
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
758
+ const click = await mountClickable(b);
759
+ assert.match(b.host.innerHTML, /data-genesis-plan-edit>Change the plan</);
760
+ assert.match(b.host.innerHTML, /Fixed: The Board Room passed the scope stage/);
761
+ assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'the plan form is not shown until asked for');
762
+ await click(fakeButton('data-genesis-plan-edit'));
763
+ assert.match(b.host.innerHTML, /data-genesis-plan>Save the plan</);
764
+ assert.match(b.host.innerHTML, /data-genesis-plan-cancel>Keep it as it was</);
765
+ assert.match(b.host.innerHTML, /value="2" checked/, 'two passes, as saved');
766
+ assert.match(b.host.innerHTML, /value="cbaas" checked/, 'advisors, as saved');
767
+ assert.match(b.host.innerHTML, /data-existing checked/, '"it already exists", as saved');
768
+ assert.match(b.host.innerHTML, /<option value="9" selected>9 days/, 'the stage length, as saved');
769
+ await click(fakeButton('data-genesis-plan-cancel'));
770
+ assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'keeping it as it was sends nothing and closes the form');
771
+ assert.ok(!b.calls.some((c) => c.startsWith('POST /founding/plan')));
772
+ });
773
+
774
+ test('task 1005107: a finished stage\'s answers are changed in place; unchanged sends nothing, a change sends only itself', async () => {
775
+ const genesis = gh.shapeGenesis({
776
+ mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
777
+ plan: { days: {} },
778
+ tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
779
+ stageAt: { strategize: '2026-10-08T00:00:00Z' },
780
+ });
781
+ const work = {
782
+ stage: 'scope', page: 'Old page',
783
+ questions: [
784
+ { id: 'what_creating', ask: 'What are you creating?', answer: { state: 'answered', text: 'A ledger' } },
785
+ { id: 'reach_who', ask: 'Who for?', answer: null },
786
+ ],
787
+ };
788
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1', work } });
789
+ const click = await mountClickable(b);
790
+ assert.match(b.host.innerHTML, /data-genesis-stage-edit="scope">Change its answers</);
791
+ await click(fakeButton('data-genesis-stage-edit', 'scope'));
792
+ assert.ok(b.calls.includes('GET /api/bongos/founding/work/scope'));
793
+ assert.match(b.host.innerHTML, /<textarea rows="3" data-answer="what_creating">A ledger<\/textarea>/, 'opens with the answer already given');
794
+ assert.match(b.host.innerHTML, /data-page>Old page</);
795
+ // Leaving everything as it was sends nothing.
796
+ const values = { '[data-answer="what_creating"]': 'A ledger', '[data-answer="reach_who"]': '', '[data-page]': 'Old page' };
797
+ b.host.querySelector = (sel) => (sel in values ? { value: values[sel] } : null);
798
+ const before = b.calls.length;
799
+ await click(fakeButton('data-genesis-stage-save'));
800
+ assert.equal(b.calls.length, before, 'nothing changed: no request');
801
+ assert.deepEqual(b.toasts.slice(-1), ['Nothing changed.']);
802
+ // A change sends only that answer.
803
+ await click(fakeButton('data-genesis-stage-edit', 'scope'));
804
+ values['[data-answer="reach_who"]'] = 'Small bakeries';
805
+ const mark = b.calls.length;
806
+ await click(fakeButton('data-genesis-stage-save'));
807
+ assert.deepEqual(b.calls.slice(mark), ['PUT /api/bongos/founding/work/scope/answers/reach_who']);
808
+ });
809
+
810
+ test('task 1005107: a government the plan cannot file is explained in words, never as a code', async () => {
811
+ const w = world();
812
+ w.government.proposeCharter = async () => ({ ok: false, reason: 'council_needs_a_rank' });
813
+ await gh.startFounding(1, w.deps());
814
+ await gh.savePlan({}, 1, w.deps());
815
+ const r = await gh.savePlan({ government: 'foundation' }, 1, w.deps());
816
+ assert.match(r.could_not[0], /needs a council with a rank/);
817
+ assert.doesNotMatch(r.could_not[0], /needs_council|council_needs/);
818
+ });
819
+
707
820
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
708
821
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
709
822
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -399,6 +399,44 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
399
399
  assert.equal(all.charter.title, 'The charter');
400
400
  });
401
401
 
402
+ await test('task 1005107: a stage the Board Room passed is read-only with a plain reason; others stay editable', async () => {
403
+ let passed = ['scope'];
404
+ const government = { genesisStageStatus: async () => ({ stages: passed.map((key) => ({ key, state: 'passed' })) }) };
405
+ const deps = { store: memStore(), government };
406
+ const w = await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, deps);
407
+ assert.equal(w.ok, false);
408
+ assert.equal(w.reason, 'stage_closed');
409
+ assert.match(w.message, /Board Room passed the scope stage/);
410
+ assert.equal((await gw.setPage('scope', 'x', 1, deps)).reason, 'stage_closed');
411
+ assert.equal((await gw.setLink('scope', 'https://x.test', 1, deps)).reason, 'stage_closed');
412
+ assert.equal((await gw.clearAnswer('scope', 'what_creating', 1, deps)).reason, 'stage_closed');
413
+ const read = await gw.readStage('scope', deps);
414
+ assert.match(read.work.locked.reason, /fixed/);
415
+ const doc = (await gw.readDocument('scope', deps)).document;
416
+ assert.equal(doc.editable, false);
417
+ assert.match(doc.locked_reason, /what was voted on/);
418
+ assert.equal((await gw.readDocuments(deps)).scope.editable, false);
419
+ // A stage not passed stays editable, and so does one with no Board Room at all.
420
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
421
+ assert.equal((await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, { store: deps.store, government: null })).ok, true);
422
+ // When the Board Room cannot be read, nothing is written.
423
+ const broken = { store: deps.store, government: { genesisStageStatus: async () => { throw new Error('down'); } } };
424
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Money' }, 1, broken)).reason, 'stage_lock_unknown');
425
+ passed = [];
426
+ assert.equal((await gw.setPage('scope', 'Now open again', 1, deps)).ok, true);
427
+ });
428
+
429
+ await test('task 1005107: a fixed stage answers stage_closed whatever was sent, and the board is read once per write', async () => {
430
+ let reads = 0;
431
+ const government = { genesisStageStatus: async () => { reads++; return { stages: [{ key: 'charter', state: 'passed' }] }; } };
432
+ const deps = { store: memStore(), government };
433
+ assert.equal((await gw.setPage('charter', 'x'.repeat(gw.LIMITS.page + 1), 1, deps)).reason, 'stage_closed', 'not page_too_long');
434
+ assert.equal((await gw.setAnswer('charter', 'no_such_question', { text: 'x' }, 1, deps)).reason, 'stage_closed', 'not unknown_question');
435
+ reads = 0;
436
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
437
+ assert.equal(reads, 1, 'the write and the read that answers it share one Board Room read');
438
+ });
439
+
402
440
  console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
403
441
  if (failed) process.exit(1);
404
442
  }
@@ -34,12 +34,15 @@ app.use(express.json());
34
34
  const router = express.Router();
35
35
  // The project's facts (task 1005129) live in project_settings: an in-memory one here.
36
36
  const settings = new Map();
37
+ let passedStages = [];
37
38
  mount(router, {
38
39
  store,
39
40
  getSetting: async (k) => (settings.has(k) ? { key: k, value: settings.get(k) } : null),
40
41
  setSetting: async (k, v) => { settings.set(k, String(v)); },
41
42
  factsMarker: null,
42
43
  payGates: null,
44
+ // The Board Room (task 1005107): the stages listed in `passedStages` read as passed.
45
+ government: { genesisStageStatus: async () => { if (!passedStages) throw new Error('board down'); return { stages: passedStages.map((key) => ({ key, state: 'passed' })) }; } },
43
46
  });
44
47
  app.use('/api/bongos', router);
45
48
  const server = app.listen(0);
@@ -100,6 +103,24 @@ try {
100
103
  assert.match(r.data.markdown, /Where its builders are: global/);
101
104
  });
102
105
 
106
+ await test('task 1005107: a write to a stage the Board Room passed is a 409 that says why', async () => {
107
+ passedStages = ['legislation'];
108
+ const r = await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } });
109
+ assert.equal(r.status, 409);
110
+ assert.equal(r.data.error.code, 'stage_closed');
111
+ assert.match(r.data.error.message, /Board Room passed the legislation stage/);
112
+ const d = await call('GET', '/founding/work/legislation/document', { as: OTHER });
113
+ assert.equal(d.data.document.editable, false);
114
+ passedStages = [];
115
+ assert.equal((await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } })).status, 200);
116
+ // When the Board Room cannot be read, the write is refused as a 503, never let through.
117
+ passedStages = null;
118
+ const down = await call('PUT', '/founding/work/legislation/page', { body: { page: 'y' } });
119
+ assert.equal(down.status, 503);
120
+ assert.equal(down.data.error.code, 'stage_lock_unknown');
121
+ passedStages = [];
122
+ });
123
+
103
124
  await test('a builder who is not the owner cannot write', async () => {
104
125
  for (const [m, p, body] of [
105
126
  ['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],