@bongos/core 1.25.17 → 1.25.19

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 (72) hide show
  1. package/.bongos-core.json +135 -65
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +17 -1
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +2 -1
  8. package/docs/api/openapi.json +288 -6
  9. package/docs/api-reference.md +14 -4
  10. package/docs/copy-inventory.md +132 -121
  11. package/docs/copy-registry.json +347 -195
  12. package/docs/file-map.md +2 -1
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +63 -63
  16. package/modules/app-host/CLAUDE.md +15 -0
  17. package/modules/app-host/module.json +20 -0
  18. package/modules/app-host/public/settings-widget.js +143 -0
  19. package/modules/app-host/routes/app-host.js +88 -0
  20. package/modules/government/charter-advice.js +103 -0
  21. package/modules/government/presets.js +42 -0
  22. package/modules/government/recommendations.js +102 -7
  23. package/modules/government/routes/government.js +1 -0
  24. package/modules/hall-ui/public/genesis-home.js +28 -3
  25. package/modules/lifecycle/first-read-catalog.js +62 -0
  26. package/modules/lifecycle/first-read-db.js +47 -9
  27. package/modules/lifecycle/first-read.js +88 -23
  28. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  29. package/modules/lifecycle/routes/first-read.js +27 -5
  30. package/modules/onboarding/genesis-home.js +68 -7
  31. package/modules/onboarding/genesis-work.js +169 -4
  32. package/modules/onboarding/routes/genesis-work.js +5 -2
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  35. package/modules/provisioning/module.json +1 -0
  36. package/modules/provisioning/seams.js +32 -1
  37. package/modules/provisioning/starter-bundles.js +1 -0
  38. package/modules/public-landing/public/assets/guide-core.js +475 -0
  39. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  40. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  41. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  42. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  43. package/modules/public-landing/public/index.html +46 -1
  44. package/modules/public-landing/public/projects.html +11 -2
  45. package/modules/ui-design/kit/serve.js +1 -1
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +24 -0
  49. package/scripts/bongos/first-read.js +45 -11
  50. package/scripts/bongos/genesis.js +29 -2
  51. package/scripts/bongos/publish-manifest.js +1 -0
  52. package/scripts/bongos/seam-wiring-guard.js +3 -2
  53. package/scripts/hall-preview/server.js +2 -0
  54. package/src/bongos/auth-admission.js +64 -0
  55. package/src/bongos/auth.js +2 -1
  56. package/src/bongos/module-scope-map.js +4 -0
  57. package/src/bongos/routes/core-update.js +3 -16
  58. package/src/bongos/routes/modules.js +11 -0
  59. package/src/bongos/serve-internal.js +20 -6
  60. package/src/module-api.js +6 -1
  61. package/src/module-loader/loader.js +2 -2
  62. package/tests/app_host.mjs +413 -0
  63. package/tests/first_read.mjs +184 -8
  64. package/tests/genesis_home.mjs +81 -1
  65. package/tests/genesis_work.mjs +103 -3
  66. package/tests/genesis_work_routes.mjs +9 -0
  67. package/tests/government_charter_advice.mjs +150 -0
  68. package/tests/landing_guide_hall.mjs +364 -0
  69. package/tests/landing_guide_scene.mjs +8 -5
  70. package/tests/module_api.mjs +1 -0
  71. package/tests/module_loader.mjs +1 -1
  72. package/tests/npm_release_where.mjs +13 -3
@@ -19,8 +19,12 @@
19
19
  // position), and every filed ref is remembered (lifecycle_repo_map_filed), so a second
20
20
  // map that names the same goal or task again plans to file nothing for it.
21
21
  //
22
- // ROOM TO GROW. Unknown top-level keys are kept, not refused — task 1005281 adds a
23
- // `recommended_modules` section the same way, with no change to this version's shape.
22
+ // ROOM TO GROW. Unknown top-level keys are kept, not refused, so a later section needs
23
+ // no change to this version's shape. `recommended_modules` (task 1005281) is the first:
24
+ // modules from THIS project's live list that would do a job better than what the code
25
+ // has (first-read-catalog.js supplies the list; a key not on it is refused on submit).
26
+ // A recommendation is a suggestion: accepting files a draft task to switch it on, and
27
+ // dismissing records the no, so a re-run does not raise it again.
24
28
  //
25
29
  // Pure — no I/O. The DB half is first-read-db.js; the HTTP surface routes/first-read.js;
26
30
  // the CLI scripts/bongos/first-read.js; the Genesis step that hands out the prompt is
@@ -41,12 +45,13 @@ const LIMITS = Object.freeze({
41
45
  GOALS: 20,
42
46
  TASKS_PER_GOAL: 30,
43
47
  NEXT: 50,
48
+ MODULES: 10,
44
49
  BYTES: 60000,
45
50
  });
46
51
  const TASK_STATES = ['done', 'in_progress', 'not_started'];
47
52
  const NEXT_KINDS = ['error', 'mishap', 'gap'];
48
53
  // Top-level keys this version reads. Anything else is kept as-is for a later reader.
49
- const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next'];
54
+ const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next', 'recommended_modules'];
50
55
  const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
51
56
  // The goal a `next` item lands under when it names no goal of the map.
52
57
  const NEXT_GOAL = Object.freeze({
@@ -55,6 +60,13 @@ const NEXT_GOAL = Object.freeze({
55
60
  why: 'Errors, mishaps and gaps the first read-through of the repository found, not tied to one goal.',
56
61
  });
57
62
 
63
+ // The goal an accepted module recommendation lands under.
64
+ const MODULES_GOAL = Object.freeze({
65
+ ref: 'goal:modules-the-first-read-recommends',
66
+ title: 'Modules the first read-through recommends',
67
+ why: 'Modules from this project\'s own list that the first read-through of the repository found would do a job better than what the code has now.',
68
+ });
69
+
58
70
  // slug(text) → a stable lower-case key: the same title always gives the same ref.
59
71
  function slug(text) {
60
72
  return String(text || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
@@ -65,6 +77,7 @@ function slug(text) {
65
77
  const goalRef = (title) => `goal:${slug(title)}`;
66
78
  const taskRef = (goalTitle, title) => `task:${slug(goalTitle)}:${slug(title)}`;
67
79
  const nextRef = (title) => `next:${slug(title)}`;
80
+ const moduleRef = (key) => `module:${key}`;
68
81
 
69
82
  // ── the prompt ───────────────────────────────────────────────────────────────
70
83
 
@@ -88,9 +101,24 @@ function exampleMap() {
88
101
  };
89
102
  }
90
103
 
91
- // firstReadPrompt({ cli }) → the full text the owner hands their own AI session. It is
92
- // also the Genesis step's description, so the hall's copy-prompt carries all of it.
93
- function firstReadPrompt({ cli = 'npx bongos' } = {}) {
104
+ // The recommended_modules entry shape, shown in the prompt beside the example map.
105
+ const MODULE_EXAMPLE = { module: '<a key from the module list>', found: 'What in the code is weak, in one or two sentences.', why: 'Why this module does that job better.', seen_at: ['src/billing/'] };
106
+
107
+ // catalogText(catalog) → the module list as prompt lines, or a line saying there is none.
108
+ function catalogText(catalog) {
109
+ if (!Array.isArray(catalog)) return ' (The module list could not be read. Leave "recommended_modules" out.)';
110
+ if (!catalog.length) return ' (This project has no modules left to switch on. Leave "recommended_modules" out.)';
111
+ return catalog.map((m) => ` - ${m.key}: ${m.title}. ${m.summary}`.trimEnd()).join('\n');
112
+ }
113
+
114
+ // firstReadPrompt({ cli, catalog }) → the full text the owner hands their own AI session.
115
+ // Without a catalog it is the Genesis step's description (fixed text, so the hall's
116
+ // copy-prompt carries all of it) and tells the session to fetch the live module list;
117
+ // with one (`bongos first-read prompt`) the list is printed in.
118
+ function firstReadPrompt({ cli = 'npx bongos', catalog } = {}) {
119
+ const modules = catalog === undefined
120
+ ? [` Get the modules this project can switch on: ${cli} first-read catalog`]
121
+ : [' The modules this project can switch on (key: name. what it does):', catalogText(catalog)];
94
122
  return [
95
123
  'First read-through of a project that already exists. You are the owner\'s own AI session, working on their computer, on their subscription. Bongos itself never reads this repository.',
96
124
  '',
@@ -101,10 +129,13 @@ function firstReadPrompt({ cli = 'npx bongos' } = {}) {
101
129
  ' - Every goal, task and next item has a short "why" (one or two sentences, in plain words) and "seen_at" (where you saw it: file paths, with a line number when it helps). Reasons and places only, never copied code.',
102
130
  ' Use exactly this shape:',
103
131
  JSON.stringify(exampleMap(), null, 2).split('\n').map((l) => ' ' + l).join('\n'),
132
+ ' - Optional "recommended_modules": compare what the code does with the modules below. Where a module would clearly do a job better than what is there (a billing system that keeps failing, say), add one entry: "module" (its key, exactly as listed), "found" (what in the code is weak), "why" (why the module is better) and "seen_at". Recommend only from the list, at most ' + LIMITS.MODULES + '. When nothing on it beats what the code already has, leave the section out: no recommendation is a good answer.',
133
+ ' Entry shape: ' + JSON.stringify(MODULE_EXAMPLE),
134
+ ...modules,
104
135
  `3. Check the file: ${cli} first-read check ${MAP_FILE} — fix anything it lists and check again.`,
105
136
  `4. Send it to Bongos: ${cli} first-read submit ${MAP_FILE} — this files nothing yet; it prints the map's number and every item's ref.`,
106
- '5. Walk the owner through the map in plain words, goal by goal. Ask which items to accept: all of them, some of them, or none.',
107
- `6. Only after the owner says yes, accept exactly what they chose: ${cli} first-read accept <map number> --all, or --items <ref>,<ref>. If they want none of it: ${cli} first-read reject <map number>. Never accept on your own. Accepted items become draft goals and tasks; nothing can be worked on until someone promotes it.`,
137
+ '5. Walk the owner through the map in plain words, goal by goal, then any recommended modules. Ask which items to accept: all of them, some of them, or none. Recommendations are suggestions; anything they say no to can be dismissed.',
138
+ `6. Only after the owner says yes, accept exactly what they chose: ${cli} first-read accept <map number> --all, or --items <ref>,<ref>. Items they turn down: ${cli} first-read dismiss <map number> --items <ref>,<ref> (a later read-through will not raise them again). If they want none of it: ${cli} first-read reject <map number>. Never accept or dismiss on your own. Accepted items become draft goals and tasks; nothing can be worked on until someone promotes it, and an accepted module is a to-do to switch it on, never switched on by itself.`,
108
139
  'Running the read-through again later is safe: anything already accepted is not filed twice.',
109
140
  ].join('\n');
110
141
  }
@@ -130,11 +161,14 @@ function seenAt(errors, where, v) {
130
161
  return out;
131
162
  }
132
163
 
133
- // validateRepoMap(raw) → { ok: true, map, items } | { ok: false, errors: [string] }
164
+ // validateRepoMap(raw, { catalog }) → { ok: true, map, items } | { ok: false, errors }
134
165
  // `map` is the normalised map (unknown top-level keys kept verbatim); `items` is the flat
135
- // review list: [{ ref, type: 'goal'|'task'|'next', title, why, seen_at, status?, kind?,
136
- // goal_ref }]. Every error names the field it is about, so a session can fix and retry.
137
- function validateRepoMap(raw) {
166
+ // review list: [{ ref, type: 'goal'|'task'|'next'|'module', title, why, seen_at, status?,
167
+ // kind?, found?, module?, goal_ref }]. Every error names the field it is about, so a
168
+ // session can fix and retry. `catalog` ([{ key, title }], or null when unreadable) is
169
+ // given on SUBMIT: every recommended key must be on it, and the title is the catalog's.
170
+ // It is left out when re-reading a stored map, which was checked when it came in.
171
+ function validateRepoMap(raw, { catalog } = {}) {
138
172
  const errors = [];
139
173
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['the map must be a JSON object'] };
140
174
  if (raw.schema !== SCHEMA) errors.push(`schema must be "${SCHEMA}"`);
@@ -143,11 +177,14 @@ function validateRepoMap(raw) {
143
177
  const summary = text(errors, 'summary', raw.summary, LIMITS.SUMMARY);
144
178
  const goalsIn = raw.goals == null ? [] : raw.goals;
145
179
  const nextIn = raw.next == null ? [] : raw.next;
180
+ const modsIn = raw.recommended_modules == null ? [] : raw.recommended_modules;
146
181
  if (!Array.isArray(goalsIn)) errors.push('goals must be a list');
147
182
  if (!Array.isArray(nextIn)) errors.push('next must be a list');
183
+ if (!Array.isArray(modsIn)) errors.push('recommended_modules must be a list');
148
184
  if (errors.length) return { ok: false, errors };
149
185
  if (goalsIn.length > LIMITS.GOALS) errors.push(`goals lists more than ${LIMITS.GOALS}`);
150
186
  if (nextIn.length > LIMITS.NEXT) errors.push(`next lists more than ${LIMITS.NEXT}`);
187
+ if (modsIn.length > LIMITS.MODULES) errors.push(`recommended_modules lists more than ${LIMITS.MODULES}`);
151
188
  if (!goalsIn.length && !nextIn.length) errors.push('the map has no goals and nothing next — list at least one');
152
189
 
153
190
  const items = [];
@@ -201,9 +238,33 @@ function validateRepoMap(raw) {
201
238
  if (ref && claimRef(ref, w)) items.push({ ref, type: 'next', title, why, seen_at: where, kind, goal_ref: gref });
202
239
  next.push({ title, kind, goal, why, seen_at: where });
203
240
  });
241
+ const known = Array.isArray(catalog) ? new Map(catalog.map((m) => [m.key, m])) : null;
242
+ const recommended = [];
243
+ modsIn.slice(0, LIMITS.MODULES).forEach((m, mi) => {
244
+ const w = `recommended_modules[${mi}]`;
245
+ if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push(`${w} must be an object`); return; }
246
+ const key = text(errors, `${w}.module`, m.module, LIMITS.TITLE);
247
+ const found = text(errors, `${w}.found`, m.found, LIMITS.WHY);
248
+ const why = text(errors, `${w}.why`, m.why, LIMITS.WHY);
249
+ const where = seenAt(errors, w, m.seen_at);
250
+ if (!key) return;
251
+ if (catalog !== undefined && !(known && known.has(key))) {
252
+ errors.push(known
253
+ ? `${w}.module "${key}" is not a module this project can switch on — use a key from the module list (first-read catalog)`
254
+ : `${w}.module: the module list could not be read here, so no module can be recommended — leave recommended_modules out`);
255
+ return;
256
+ }
257
+ const title = known ? known.get(key).title : (text([], '', m.title, LIMITS.TITLE) || key);
258
+ const ref = moduleRef(key);
259
+ if (seen.has(ref)) { errors.push(`${w}.module repeats a module already recommended`); return; }
260
+ seen.add(ref);
261
+ items.push({ ref, type: 'module', module: key, title, found, why, seen_at: where, goal_ref: MODULES_GOAL.ref });
262
+ recommended.push({ module: key, title, found, why, seen_at: where });
263
+ });
204
264
  if (errors.length) return { ok: false, errors };
205
265
 
206
266
  const map = { schema: SCHEMA, summary, goals, next };
267
+ if (recommended.length) map.recommended_modules = recommended;
207
268
  // Forward-compatible: a later section (task 1005281's recommended_modules) rides along.
208
269
  // Prototype-shaped keys are never copied (CWE-1321): a later section is plain data.
209
270
  for (const [k, v] of Object.entries(raw)) if (!KNOWN_KEYS.includes(k) && !UNSAFE_KEYS.includes(k)) map[k] = v;
@@ -213,19 +274,24 @@ function validateRepoMap(raw) {
213
274
  // ── acceptance ───────────────────────────────────────────────────────────────
214
275
 
215
276
  function describe(item, mapId) {
216
- const lines = [item.why, '', `Seen at: ${item.seen_at.join(', ')}`];
277
+ const lines = item.type === 'module'
278
+ ? [`Found: ${item.found}`, '', `Why the ${item.title} module: ${item.why}`, '', `Seen at: ${item.seen_at.join(', ')}`,
279
+ 'Switching it on is done on the Modules page by whoever may turn modules on; this task is the reminder.']
280
+ : [item.why, '', `Seen at: ${item.seen_at.join(', ')}`];
217
281
  if (item.status && item.status !== 'not_started') lines.push(`Status when read: ${item.status.replace('_', ' ')}`);
218
282
  lines.push('', `From the first read-through of the repository (map ${mapId}, ${item.ref}). A draft: promote it when it is right.`);
219
283
  return lines.join('\n');
220
284
  }
221
285
 
222
- // planAcceptance({ items, mapId, select, filedRefs }) → { goals, tasks, skipped, unknown }
223
- // select 'all' | [ref] — what the owner said yes to. Nothing else is planned.
224
- // filedRefs Set of refs already filed by an earlier accept (any map) — never filed again.
286
+ // planAcceptance({ items, mapId, select, filedRefs, dismissedRefs }) → { goals, tasks, skipped, unknown }
287
+ // select 'all' | [ref] — what the owner said yes to. Nothing else is planned.
288
+ // filedRefs Set of refs already filed by an earlier accept (any map) — never filed again.
289
+ // dismissedRefs Set of refs the owner said no to before — 'all' skips them; naming one
290
+ // explicitly files it (the owner changed their mind).
225
291
  // A chosen task or next item brings its goal along (a task needs a home); a goal that is
226
292
  // already filed is reused by ref. A task whose status is `done` is history, not work:
227
293
  // it is shown in review but never filed.
228
- function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
294
+ function planAcceptance({ items, mapId, select, filedRefs = new Set(), dismissedRefs = new Set() }) {
229
295
  const byRef = new Map(items.map((i) => [i.ref, i]));
230
296
  const wanted = select === 'all' ? items.map((i) => i.ref) : [...new Set(Array.isArray(select) ? select : [])];
231
297
  const unknown = wanted.filter((r) => !byRef.has(r));
@@ -234,10 +300,8 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
234
300
  const skipped = [];
235
301
  const needGoal = (ref) => {
236
302
  if (!ref || filedRefs.has(ref) || goals.has(ref)) return;
237
- if (ref === NEXT_GOAL.ref) {
238
- goals.set(ref, { ref, title: NEXT_GOAL.title, description: NEXT_GOAL.why });
239
- return;
240
- }
303
+ const fixed = ref === NEXT_GOAL.ref ? NEXT_GOAL : ref === MODULES_GOAL.ref ? MODULES_GOAL : null;
304
+ if (fixed) { goals.set(ref, { ref, title: fixed.title, description: fixed.why }); return; }
241
305
  const g = byRef.get(ref);
242
306
  if (g) goals.set(ref, { ref, title: g.title, description: describe(g, mapId) });
243
307
  };
@@ -245,13 +309,14 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
245
309
  const it = byRef.get(ref);
246
310
  if (!it) continue;
247
311
  if (filedRefs.has(ref)) { skipped.push({ ref, reason: 'already_filed' }); continue; }
312
+ if (select === 'all' && dismissedRefs.has(ref)) { skipped.push({ ref, reason: 'dismissed' }); continue; }
248
313
  if (it.type === 'goal') { needGoal(ref); continue; }
249
314
  if (it.type === 'task' && it.status === 'done') { skipped.push({ ref, reason: 'already_done' }); continue; }
250
315
  needGoal(it.goal_ref);
251
316
  tasks.push({
252
317
  ref,
253
318
  goalRef: it.goal_ref,
254
- title: it.title,
319
+ title: it.type === 'module' ? `Switch on the ${it.title} module` : it.title,
255
320
  description: describe(it, mapId),
256
321
  kind: it.type === 'next' && it.kind !== 'gap' ? 'bug' : 'feature',
257
322
  });
@@ -260,6 +325,6 @@ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
260
325
  }
261
326
 
262
327
  module.exports = {
263
- SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL,
328
+ SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL, MODULES_GOAL,
264
329
  exampleMap, firstReadPrompt, validateRepoMap, planAcceptance,
265
330
  };
@@ -0,0 +1,19 @@
1
+ -- lifecycle_023_repo_map_dismissed.sql — the owner's "no" to an item of a repository
2
+ -- map is remembered too (task 1005281, goal 1000106).
3
+ --
4
+ -- A module recommendation is a suggestion the owner accepts OR dismisses. A dismissed
5
+ -- item is a row in lifecycle_repo_map_filed with kind 'dismissed' and no entity, so a
6
+ -- later read-through's "accept all" does not raise it again. Naming it explicitly later
7
+ -- still files it: the accept upserts over a 'dismissed' row only, never over a filed one.
8
+ -- Works for every item type, not only modules.
9
+
10
+ ALTER TABLE lifecycle_repo_map_filed ALTER COLUMN entity_id DROP NOT NULL;
11
+
12
+ ALTER TABLE lifecycle_repo_map_filed DROP CONSTRAINT IF EXISTS lifecycle_repo_map_filed_kind_chk;
13
+ ALTER TABLE lifecycle_repo_map_filed ADD CONSTRAINT lifecycle_repo_map_filed_kind_chk
14
+ CHECK (kind IN ('goal', 'task', 'dismissed'));
15
+
16
+ -- A filed item points at what it became; a dismissed one points at nothing.
17
+ ALTER TABLE lifecycle_repo_map_filed DROP CONSTRAINT IF EXISTS lifecycle_repo_map_filed_entity_chk;
18
+ ALTER TABLE lifecycle_repo_map_filed ADD CONSTRAINT lifecycle_repo_map_filed_entity_chk
19
+ CHECK ((kind = 'dismissed') = (entity_id IS NULL));
@@ -13,23 +13,34 @@ const express = require('express');
13
13
  const api = require('../../../src/module-api');
14
14
  const fr = require('../first-read');
15
15
  const store = require('../first-read-db');
16
+ const modulesCatalog = require('../first-read-catalog');
16
17
 
17
18
  const { asyncHandler, parseId, validateOrRespond } = api;
18
19
 
19
20
  module.exports = function buildFirstReadRouter() {
20
21
  const router = express.Router();
21
22
 
22
- // rank: any-builder — the prompt and the shape are instructions, not project data.
23
- router.get('/first-read/prompt', api.requireBuilder, (req, res) => {
24
- res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt(), example: fr.exampleMap(), limits: fr.LIMITS });
25
- });
23
+ // rank: any-builder — the prompt and the shape are instructions, not project data. The
24
+ // prompt carries the live module list (task 1005281), so a session recommends from it.
25
+ router.get('/first-read/prompt', api.requireBuilder, asyncHandler('GET /first-read/prompt', async (req, res) => {
26
+ const catalog = await modulesCatalog.switchOnCatalog();
27
+ res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt({ catalog }), example: fr.exampleMap(), limits: fr.LIMITS, catalog });
28
+ }, { errorCode: 'first_read_prompt_failed' }));
29
+
30
+ // rank: any-builder — the modules this project can switch on, the only ones a map may
31
+ // recommend. `available: false` when the list cannot be read here.
32
+ router.get('/first-read/catalog', api.requireBuilder, asyncHandler('GET /first-read/catalog', async (req, res) => {
33
+ const catalog = await modulesCatalog.switchOnCatalog();
34
+ res.json({ available: Array.isArray(catalog), modules: catalog || [] });
35
+ }, { errorCode: 'first_read_catalog_failed' }));
26
36
 
27
37
  // rank: goal.create — a map is a planning proposal for this project. Stores it
28
38
  // pending; files nothing. A malformed map is refused with every error named.
29
39
  router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
30
40
  // The envelope is checked here; the map inside it by first-read.js, which names every error.
31
41
  if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
32
- const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id });
42
+ const catalog = await modulesCatalog.switchOnCatalog();
43
+ const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id, catalog });
33
44
  if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
34
45
  return res.status(201).json({
35
46
  map_id: out.map_id, status: out.status,
@@ -66,6 +77,17 @@ module.exports = function buildFirstReadRouter() {
66
77
  return res.json(out);
67
78
  }, { errorCode: 'first_read_accept_failed' }));
68
79
 
80
+ // rank: goal.create — the owner's no to named items (a module recommendation, usually).
81
+ // Files nothing; a later "accept all" passes them by.
82
+ router.post('/first-read/maps/:id/dismiss', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/dismiss', async (req, res) => {
83
+ const mapId = parseId(req, res, { positiveInt: true });
84
+ if (mapId == null) return undefined;
85
+ if (validateOrRespond(req, res, { items: { required: true, type: 'array', itemsType: 'string', maxItems: 500 } })) return undefined;
86
+ const out = await store.dismissItems({ mapId, refs: req.body.items, builderId: req.builder.id });
87
+ if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
88
+ return res.json(out);
89
+ }, { errorCode: 'first_read_dismiss_failed' }));
90
+
69
91
  // rank: goal.create — the owner's no. Files nothing.
70
92
  router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
71
93
  const mapId = parseId(req, res, { positiveInt: true });
@@ -27,6 +27,15 @@
27
27
  // as `board.genesis_stage.passed` (the route factory listens), and every read
28
28
  // reconciles again, so a missed nudge cannot strand a stage.
29
29
  //
30
+ // WITH NO BOARD ROOM (task 1005115; owner, 2026-11-07: "I would automatically proceed").
31
+ // A project without the government module has nobody to put a stage to, so a stage
32
+ // whose tasks are all finished moves on BY ITSELF — reconcile does it, on every read and
33
+ // on every ship — and the view says so (`moved`: from, to, what woke). There is no
34
+ // close button to press on that path. Where there IS a Board Room the sitting stays:
35
+ // a team deciding together is not the same act as a solo founder acknowledging their
36
+ // own work. Either way, each stage leaves a DOCUMENT behind (genesis-work.js
37
+ // documentOf) that the founder can edit at any time without reopening the stage.
38
+ //
30
39
  // THE BOUNDARY. Core through ../../src/module-api.js; the lifecycle and government
31
40
  // modules through their ports only (resolveOptional), never a require.
32
41
 
@@ -158,7 +167,11 @@ function taskState(t) {
158
167
  // task, named in `waits_on`) or 'open' — decided here, never re-derived in a browser.
159
168
  // board { stage: genesisStageStatus row } or {}
160
169
  // stageAt { stage: ISO } — when each stage began, for its due date
161
- function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
170
+ // boardRoom false on a project with no government module: stages advance by
171
+ // themselves, so nothing is ever "closable" and the alive door opens on
172
+ // finished legislation tasks (task 1005115)
173
+ // documents { stage: { title, filled, updated_at, editable } } from genesis-work.js
174
+ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardRoom = true, documents = {} }) {
162
175
  const at = fm.STAGES.indexOf(mode.stage);
163
176
  const stages = PROPER.map((key) => {
164
177
  const info = STAGE_INFO[key];
@@ -190,7 +203,10 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
190
203
  total: list.length,
191
204
  // task 1005199: this stage (the current one or any later one) can be put to
192
205
  // the Board Room now — its tasks are all finished and it is not already there.
193
- can_close: at >= 1 && idx >= at && allFinished && !onBoard,
206
+ can_close: boardRoom && at >= 1 && idx >= at && allFinished && !onBoard,
207
+ // What this stage left behind (task 1005115): null where the store is not read.
208
+ document: documents[key] || null,
209
+ all_finished: allFinished,
194
210
  board: b ? {
195
211
  state: b.state, item_id: b.item_id || null, needs: b.needs || null,
196
212
  one_vote_decides: !!b.one_vote_decides, waiting_on: b.waiting_on || [],
@@ -200,6 +216,14 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
200
216
  const now = stages.find((s) => s.state === 'now') || null;
201
217
  const sitting = now && now.board && now.board.state === 'sitting';
202
218
  const passed = now && now.board && now.board.state === 'passed';
219
+ // The last move, so a presentation can say plainly that it happened and what opened
220
+ // (task 1005115). Derived from the stage stamps, never stored twice.
221
+ const prev = at >= 2 ? fm.STAGES[at - 1] : null;
222
+ const moved = prev && stageAt[mode.stage] ? {
223
+ from: prev, to: mode.stage, at: stageAt[mode.stage],
224
+ how: boardRoom ? 'board_passed' : 'tasks_finished',
225
+ opened: fm.WAKES[prev] || [],
226
+ } : null;
203
227
  return {
204
228
  stage: mode.stage,
205
229
  stage_number: mode.stage_number,
@@ -211,9 +235,12 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
211
235
  demo: plan && plan.demo ? plan.demo : null,
212
236
  stages,
213
237
  // The close door: every task of the current stage finished, and not already sitting.
214
- can_close: !!(now && now.total > 0 && now.finished === now.total && !sitting && !passed),
215
- // Alive: the last stage has passed its sitting.
216
- can_come_alive: !!(now && now.key === 'legislation' && passed),
238
+ can_close: !!(boardRoom && now && now.total > 0 && now.finished === now.total && !sitting && !passed),
239
+ // Alive: the last stage has passed its sitting — or, with no Board Room, its tasks
240
+ // are all finished.
241
+ can_come_alive: !!(now && now.key === 'legislation' && (boardRoom ? passed : now.total > 0 && now.finished === now.total)),
242
+ board_room: boardRoom,
243
+ moved,
217
244
  };
218
245
  }
219
246
 
@@ -226,9 +253,18 @@ function seams(deps = {}) {
226
253
  set: deps.setSetting || api.projectSettings.set,
227
254
  lifecycle: deps.lifecycle !== undefined ? deps.lifecycle : api.resolveOptional('lifecycle.kickoffSeed'),
228
255
  government: deps.government !== undefined ? deps.government : api.resolveOptional('government'),
256
+ // Each stage's document summary (task 1005115): the founder's store unless injected.
257
+ // Fails soft in readGenesis, so a hall without the tables still renders.
258
+ documents: deps.documents !== undefined ? deps.documents : () => require('./genesis-work').readDocuments(),
229
259
  now: deps.now,
230
260
  };
231
261
  }
262
+
263
+ // hasBoardRoom(gov) — can a stage be put to a sitting at all? Without one a finished
264
+ // stage advances by itself (task 1005115).
265
+ const hasBoardRoom = (gov) => !!(gov && (typeof gov.closeGenesisStage === 'function'
266
+ || typeof gov.openGenesisStageClose === 'function'));
267
+ const allDone = (list) => !!(list && list.length && list.every((t) => FINISHED.includes(t.status)));
232
268
  const modeDeps = (s) => ({ getSetting: s.get, setSetting: s.set, government: s.government, now: s.now });
233
269
 
234
270
  async function boardByStage(gov) {
@@ -245,6 +281,15 @@ async function boardByStage(gov) {
245
281
  async function reconcile(s) {
246
282
  let mode = await fm.foundingModeFor(modeDeps(s));
247
283
  if (!mode || !PROPER.includes(mode.stage) || mode.stage === 'legislation') return mode;
284
+ if (!hasBoardRoom(s.government)) {
285
+ // No Board Room: finished work IS the decision (task 1005115). Move past every
286
+ // stage whose tasks are all finished, in order; legislation waits for the alive door.
287
+ const all = s.lifecycle && typeof s.lifecycle.genesisTasks === 'function' ? await s.lifecycle.genesisTasks() : {};
288
+ while (mode && PROPER.includes(mode.stage) && mode.stage !== 'legislation' && allDone(all[mode.stage])) {
289
+ mode = await fm.setFoundingMode({ stage: fm.STAGES[fm.STAGES.indexOf(mode.stage) + 1] }, null, modeDeps(s));
290
+ }
291
+ return mode;
292
+ }
248
293
  const board = await boardByStage(s.government);
249
294
  while (mode && PROPER.includes(mode.stage) && mode.stage !== 'legislation'
250
295
  && board[mode.stage] && board[mode.stage].state === 'passed') {
@@ -268,7 +313,11 @@ async function readGenesis(deps = {}) {
268
313
  const row = await s.get(fm.stageAtKey(key));
269
314
  if (row && row.value) stageAt[key] = row.value;
270
315
  }
271
- return shapeGenesis({ mode, plan, tasks, board, stageAt });
316
+ let documents = {};
317
+ if (typeof s.documents === 'function') {
318
+ try { documents = (await s.documents()) || {}; } catch (_) { documents = {}; } // the home still renders without them
319
+ }
320
+ return shapeGenesis({ mode, plan, tasks, board, stageAt, boardRoom: hasBoardRoom(s.government), documents });
272
321
  }
273
322
 
274
323
  // startFounding — a brand-new project enters genesis (founding-birth.js calls this once
@@ -403,6 +452,13 @@ async function closeStage(stageKey, builderId, deps = {}) {
403
452
  // stage's pass waits for the earlier ones. A stage already behind, or the decide
404
453
  // pre-stage, is still refused.
405
454
  if (!PROPER.includes(stageKey)) return { ok: false, reason: 'unknown_stage' };
455
+ // No Board Room (task 1005115): the stage already moved on by itself when its tasks
456
+ // finished, so asking to close it again is answered with what happened, not refused.
457
+ if (!hasBoardRoom(s.government) && PROPER.includes(mode.stage)
458
+ && fm.STAGES.indexOf(stageKey) < fm.STAGES.indexOf(mode.stage)) {
459
+ // next_stage is where the project IS now, which may be several stages on.
460
+ return { ok: true, closed: true, stage: stageKey, next_stage: mode.stage, item_id: null, confirm: null, already: true };
461
+ }
406
462
  if (!PROPER.includes(mode.stage) || fm.STAGES.indexOf(stageKey) < fm.STAGES.indexOf(mode.stage)) {
407
463
  return { ok: false, reason: 'not_current_stage', stage: mode.stage };
408
464
  }
@@ -546,7 +602,12 @@ async function putFinishedStages(deps = {}) {
546
602
  // stage; on a project that is not founding this costs the one genesis.enabled read.
547
603
  // Best-effort: the founder's own close button is still there if this misses.
548
604
  async function onTaskShipped(deps = {}) {
549
- try { return await putFinishedStages(deps); } catch (_) { return []; }
605
+ try {
606
+ const s = seams(deps);
607
+ // No Board Room: the ship that finishes a stage moves the project on (task 1005115).
608
+ if (!hasBoardRoom(s.government)) { await reconcile(s); return []; }
609
+ return await putFinishedStages(deps);
610
+ } catch (_) { return []; }
550
611
  }
551
612
 
552
613
  module.exports = {