@bongos/core 1.25.17 → 1.25.18

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 (68) hide show
  1. package/.bongos-core.json +131 -61
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -1
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  8. package/docs/api/openapi.json +243 -6
  9. package/docs/api-reference.md +12 -3
  10. package/docs/copy-inventory.md +118 -108
  11. package/docs/copy-registry.json +315 -172
  12. package/docs/file-map.md +2 -1
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +62 -62
  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/lifecycle/first-read-catalog.js +62 -0
  25. package/modules/lifecycle/first-read-db.js +47 -9
  26. package/modules/lifecycle/first-read.js +88 -23
  27. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  28. package/modules/lifecycle/routes/first-read.js +27 -5
  29. package/modules/onboarding/genesis-work.js +101 -4
  30. package/modules/onboarding/routes/genesis-work.js +2 -2
  31. package/modules/platform-identity/module.json +1 -1
  32. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  33. package/modules/provisioning/module.json +1 -0
  34. package/modules/provisioning/seams.js +32 -1
  35. package/modules/provisioning/starter-bundles.js +1 -0
  36. package/modules/public-landing/public/assets/guide-core.js +475 -0
  37. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  38. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  39. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  41. package/modules/public-landing/public/index.html +46 -1
  42. package/modules/public-landing/public/projects.html +11 -2
  43. package/modules/ui-design/kit/serve.js +1 -1
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +18 -0
  47. package/scripts/bongos/first-read.js +45 -11
  48. package/scripts/bongos/genesis.js +21 -1
  49. package/scripts/bongos/publish-manifest.js +1 -0
  50. package/scripts/bongos/seam-wiring-guard.js +3 -2
  51. package/scripts/hall-preview/server.js +2 -0
  52. package/src/bongos/auth-admission.js +64 -0
  53. package/src/bongos/auth.js +2 -1
  54. package/src/bongos/module-scope-map.js +4 -0
  55. package/src/bongos/routes/core-update.js +3 -16
  56. package/src/bongos/routes/modules.js +11 -0
  57. package/src/bongos/serve-internal.js +20 -6
  58. package/src/module-api.js +6 -1
  59. package/src/module-loader/loader.js +2 -2
  60. package/tests/app_host.mjs +413 -0
  61. package/tests/first_read.mjs +184 -8
  62. package/tests/genesis_work.mjs +74 -3
  63. package/tests/government_charter_advice.mjs +150 -0
  64. package/tests/landing_guide_hall.mjs +364 -0
  65. package/tests/landing_guide_scene.mjs +8 -5
  66. package/tests/module_api.mjs +1 -0
  67. package/tests/module_loader.mjs +1 -1
  68. 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 });
@@ -41,7 +41,8 @@
41
41
  //
42
42
  // WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
43
43
  // Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
44
- // charter, legalize and legislation adopt it when they are next touched. A stage with
44
+ // charter asks how decisions should go and the founder's principles (task 1005119);
45
+ // legalize and legislation adopt it when they are next touched. A stage with
45
46
  // no questions still takes a free page and a link.
46
47
  //
47
48
  // THE BOUNDARY. Pure apart from the store. The store is injected (tests) or the
@@ -57,6 +58,9 @@ const STATES = Object.freeze(['answered', 'not_sure', 'skipped']);
57
58
  const ORIGINS = Object.freeze(['founder', 'assisted', 'carried_over']);
58
59
  const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
60
 
61
+ // A closed question's options: [key, label] pairs, frozen.
62
+ const choices = (pairs) => Object.freeze(pairs.map(([key, label]) => Object.freeze({ key, label })));
63
+
60
64
  // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
65
  // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
66
  // of a good answer, shown under the question in every door. `group` (optional) is a
@@ -83,6 +87,47 @@ const QUESTIONS = Object.freeze({
83
87
  Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
88
  hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
89
  ]),
90
+ // CHARTER (task 1005119): how decisions should go, then the founder's principles.
91
+ // A question with `choices` is a CLOSED one: an answer is one of its keys, because
92
+ // the government module's recommender reads it (recommendations.js CHARTER_ANSWERS
93
+ // and presets.js PRINCIPLE_ANSWERS hold the same keys, by test; a module may not
94
+ // import a sibling, ADR 0083). The prose ones shape the drafted bylaws and code of
95
+ // conduct in the founder's own words. Every one is skippable and "not sure yet".
96
+ charter: Object.freeze([
97
+ Object.freeze({ id: 'final_say', group: 'How decisions go', ask: 'When people disagree, who should have the final say?',
98
+ hint: 'Who settles it in the end. This is who gets a vote.',
99
+ choices: choices([['founder', 'Me, the founder'], ['trusted', 'The people who have earned trust by shipping'],
100
+ ['everyone', 'Everyone on the project'], ['council', 'A small group I appoint']]) }),
101
+ Object.freeze({ id: 'agreement', group: 'How decisions go', ask: 'How much agreement should a decision need?',
102
+ hint: 'Fewer means faster; more means everyone is bought in.',
103
+ choices: choices([['first_ratifier', 'One yes is enough'], ['consent', 'It passes unless someone objects'],
104
+ ['majority', 'More than half must say yes'], ['unanimous', 'Everyone who votes must agree']]) }),
105
+ Object.freeze({ id: 'strangers', group: 'How decisions go', ask: 'Do you expect people you have not met to show up and contribute?',
106
+ hint: 'Whether newcomers should get a say straight away or earn it first.',
107
+ choices: choices([['yes', 'Yes'], ['no', 'No, it is people I know']]) }),
108
+ Object.freeze({ id: 'answers_to', group: 'How decisions go', ask: 'Is there anyone outside the project it answers to: funders, a mission, a legal entity?',
109
+ hint: 'Something the project must stay true to, whoever is building it.',
110
+ choices: choices([['yes', 'Yes'], ['no', 'No']]) }),
111
+ Object.freeze({ id: 'step_back', group: 'How decisions go', ask: 'Do you want to be able to step back later and hand this over?',
112
+ hint: 'Whether the project should keep deciding well without you.',
113
+ choices: choices([['yes', 'Yes'], ['no', 'No, I plan to stay at the helm']]) }),
114
+ Object.freeze({ id: 'speed', group: 'How decisions go', ask: 'Would you rather decide fast, or have everyone bought in?',
115
+ hint: 'When the two pull apart, which wins.',
116
+ choices: choices([['fast', 'Decide fast'], ['bought_in', 'Everyone bought in']]) }),
117
+ Object.freeze({ id: 'trust_level', group: 'Principles', ask: 'How much should people be trusted to work without checking in first?',
118
+ hint: 'This shapes how strict the rules for the project\'s AI agents are.',
119
+ choices: choices([['a_lot', 'A lot: let them get on with it'], ['some', 'Some: a few rules everyone follows'],
120
+ ['little', 'Little: tight rules, checked often']]) }),
121
+ Object.freeze({ id: 'catch_or_fix', group: 'Principles', ask: 'When something goes wrong, would you rather have caught it early, or move fast and fix it?',
122
+ hint: 'Neither is wrong. It sets how much is checked before work goes ahead.',
123
+ choices: choices([['catch_early', 'Catch it early'], ['move_fast', 'Move fast and fix it']]) }),
124
+ Object.freeze({ id: 'leave_behaviour', group: 'Principles', ask: 'What behaviour would make you ask someone to leave?',
125
+ hint: 'The line nobody crosses. It becomes the heart of the code of conduct.' }),
126
+ Object.freeze({ id: 'feel', group: 'Principles', ask: 'What do you want this project to feel like to work on?',
127
+ hint: 'A few words: calm, fast, careful, playful, serious.' }),
128
+ Object.freeze({ id: 'write_down', group: 'Principles', ask: 'Is there anything you want written down now, so it is not argued about later?',
129
+ hint: 'A rule, a promise or a boundary. It goes into the bylaws as you wrote it.' }),
130
+ ]),
86
131
  strategize: Object.freeze([
87
132
  Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
88
133
  hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
@@ -116,6 +161,14 @@ function checkAnswer(stage, questionId, body = {}) {
116
161
  const words = text(b.text);
117
162
  if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
118
163
  if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
164
+ // A closed question's answer is one of its keys (or its label, read back as the key),
165
+ // so the recommender reading it never has to guess at prose.
166
+ const q = questionFor(stage, questionId);
167
+ if (state === 'answered' && q.choices) {
168
+ const hit = q.choices.find((c) => c.key === words || c.label.toLowerCase() === words.toLowerCase());
169
+ if (!hit) return { ok: false, reason: 'not_a_choice', expected: q.choices.map((c) => c.key) };
170
+ return { ok: true, value: { state, answer_md: hit.key, origin } };
171
+ }
119
172
  return { ok: true, value: { state, answer_md: words || null, origin } };
120
173
  }
121
174
 
@@ -130,6 +183,7 @@ function checkLink(url) {
130
183
 
131
184
  // ── pure: the view ────────────────────────────────────────────────────────────
132
185
 
186
+ const labelOf = (q, key) => { const c = (q.choices || []).find((x) => x.key === key); return c ? c.label : null; };
133
187
  const iso = (v) => (v ? new Date(v).toISOString() : null);
134
188
 
135
189
  // shapeStage(stage, work, answers) → one stage's record as every door sees it.
@@ -141,8 +195,11 @@ function shapeStage(stage, work, answers = []) {
141
195
  const a = byId.get(q.id);
142
196
  return {
143
197
  id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
198
+ choices: q.choices || null,
144
199
  answer: a ? {
145
200
  state: a.state, text: a.answer_md || null, origin: a.origin,
201
+ // a closed answer's words, for a reader who should not see the key
202
+ choice_label: q.choices && a.state === 'answered' ? labelOf(q, a.answer_md) : null,
146
203
  // A carried-over answer is shown as such until the founder makes it theirs.
147
204
  carried_over: a.origin === 'carried_over',
148
205
  updated_at: iso(a.updated_at),
@@ -190,7 +247,7 @@ function contextMarkdown(stages) {
190
247
  out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
191
248
  for (const q of said) {
192
249
  const a = q.answer;
193
- const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
250
+ const body = a.state === 'answered' ? (a.choice_label || a.text) : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
194
251
  out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
195
252
  }
196
253
  if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
@@ -223,7 +280,11 @@ function assistPrompt(stage) {
223
280
  ];
224
281
  if (qs.length) {
225
282
  lines.push('The questions (the id in brackets is what you record the answer under):');
226
- qs.forEach((q, i) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
283
+ qs.forEach((q, i) => {
284
+ lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`);
285
+ // A closed question records one of its keys, never prose.
286
+ if (q.choices) lines.push(` Record one of: ${q.choices.map((c) => `${c.key} (${c.label})`).join(' · ')}`);
287
+ });
227
288
  lines.push('');
228
289
  }
229
290
  lines.push('To see what I have already said: npx bongos genesis show ' + stage);
@@ -316,7 +377,43 @@ async function readAll(deps = {}) {
316
377
  async function readStage(stage, deps = {}) {
317
378
  if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
318
379
  const all = await readAll(deps);
319
- return { ok: true, work: all.find((s) => s.stage === stage) };
380
+ const work = all.find((s) => s.stage === stage);
381
+ if (stage === 'charter') work.advice = await charterAdvice(work, deps);
382
+ return { ok: true, work };
383
+ }
384
+
385
+ // charterAdvice(work, deps) — THE CHARTER STAGE'S RECOMMENDATION (task 1005119): the
386
+ // founder's answers, handed to the government module's one recommender through its
387
+ // port. Closed answers go as their keys, prose ones as the founder's words; "not sure
388
+ // yet" and skipped are not sent, so the recommender falls back to what it does
389
+ // without them. The head count is the founder's own expected number from the plan
390
+ // when they gave one. Null with no government module, and null (never a failed read)
391
+ // if the advice cannot be built: the questions still work without it.
392
+ async function charterAdvice(work, deps = {}) {
393
+ try {
394
+ const api = deps.api || require('../../src/module-api');
395
+ const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
396
+ if (!gov || typeof gov.charterAdvice !== 'function') return null;
397
+ const answers = {};
398
+ const prose = {};
399
+ for (const q of work.questions) {
400
+ if (!q.answer || q.answer.state !== 'answered') continue;
401
+ (q.choices ? answers : prose)[q.id] = q.answer.text;
402
+ }
403
+ let headcount = null;
404
+ const get = deps.getSetting || (api.projectSettings && api.projectSettings.get);
405
+ if (get) {
406
+ const row = await get('founding.plan');
407
+ try {
408
+ const v = row && row.value;
409
+ const plan = typeof v === 'string' ? JSON.parse(v) : v;
410
+ headcount = (plan && plan.expected_builders) || null;
411
+ } catch (_) { headcount = null; }
412
+ }
413
+ return await gov.charterAdvice({ answers, prose, headcount });
414
+ } catch (_) {
415
+ return null;
416
+ }
320
417
  }
321
418
 
322
419
  async function setDoor(stage, door, by, deps = {}) {
@@ -15,7 +15,7 @@ const work = require('../genesis-work');
15
15
 
16
16
  const REFUSALS = {
17
17
  unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
18
- empty_answer: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
18
+ empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
19
19
  };
20
20
 
21
21
  // `deps` is for tests only (an in-memory store); production passes none.
@@ -25,7 +25,7 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
25
25
  try {
26
26
  const r = await fn(req);
27
27
  if (r && r.ok === false) {
28
- return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason } });
28
+ return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
29
29
  }
30
30
  res.set('Cache-Control', 'no-store');
31
31
  res.json(r);
@@ -12,7 +12,7 @@
12
12
  "maintainer": "Cloud Bongos maintainers",
13
13
  "maintenance": { "status": "core-maintained" },
14
14
  "contributes": {
15
- "routes": ["sso", "sso-invites", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
15
+ "routes": ["sso", "sso-invites", "sso-app-status", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search", "community-leaderboard", "guilds", "guilds-public"],
16
16
  "migrations": true,
17
17
  "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
18
18
  },
@@ -0,0 +1,65 @@
1
+ // modules/platform-identity/routes/sso-app-status.js — a hosted project asks the hub where
2
+ // its own APP runs (task 1004571). Server-to-server, authenticated by client_id +
3
+ // client_secret like the membership check-in and the invite notices; the asker is core's
4
+ // readAppStatusFromHub (src/bongos/auth-admission.js), and its hall shows the answer in the
5
+ // options menu.
6
+ //
7
+ // POST /sso/app-status { client_id, client_secret } → { ok, known, app, sandbox }
8
+ //
9
+ // A PROJECT READS ITS OWN ROW, NEVER ANOTHER'S. The project is the authenticated client
10
+ // itself, named by both halves of the ADR 0246 projection (client_id = slug at the client's
11
+ // registered origin) — nothing in the body can name a different one. The row and its
12
+ // app-status reading belong to the provisioning module, so this asks through the
13
+ // `provisioning.projectAppStatus` port; a hub without provisioning answers `known: false`.
14
+ //
15
+ // WHAT IT CARRIES is app-status.js's shape as is: the project is already trusted with its
16
+ // own setup (it is the project's own hall asking). Its hall decides, per viewer, what an
17
+ // owner sees beyond the state. Nothing here calls Render.
18
+ //
19
+ // WIRE SKEW (ADR 0352): a NEW route, so an older hub answers 404 and the hall says it could
20
+ // not ask. The body is validated strictly, so a field added later must ride a header, or ship
21
+ // on the hub and be released before any instance sends it.
22
+ 'use strict';
23
+
24
+ const express = require('express');
25
+ const api = require('../../../src/module-api');
26
+ const pi = require('../platform-identity');
27
+
28
+ const log = api.logger('platform-identity');
29
+ const { pool, validateOrRespond } = api;
30
+
31
+ /** The host a client's registered origin names, lowercased; null for no usable origin. */
32
+ function originHost(origin) {
33
+ try { return new URL(String(origin || '')).host.toLowerCase() || null; } catch { return null; }
34
+ }
35
+
36
+ module.exports = function ssoAppStatusRoutes({ resolvePort = (name) => api.resolveOptional(name), db = pool } = {}) {
37
+ const router = express.Router();
38
+
39
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
40
+ router.post('/sso/app-status', async (req, res) => {
41
+ if (validateOrRespond(req, res, {
42
+ client_id: { required: true, type: 'string', maxLength: 200 },
43
+ client_secret: { required: true, type: 'string', maxLength: 512 },
44
+ }, { strict: true })) return undefined;
45
+ const { client_id, client_secret } = req.body;
46
+ try {
47
+ const client = await pi.getClient(client_id, { pool: db });
48
+ if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
49
+ return res.fail('invalid_client', 401);
50
+ }
51
+ const read = resolvePort('provisioning.projectAppStatus');
52
+ const host = originHost(client.origin);
53
+ const found = typeof read === 'function' && host ? await read({ slug: client.client_id, host }) : null;
54
+ if (!found) return res.json({ ok: true, known: false, app: null, sandbox: null });
55
+ return res.json({ ok: true, known: true, app: found.app || null, sandbox: found.sandbox || null });
56
+ } catch (err) {
57
+ log.error('[platform-identity] /sso/app-status', err && err.message);
58
+ return res.fail('app_status_failed', { status: 500, message: 'internal error' });
59
+ }
60
+ });
61
+
62
+ return router;
63
+ };
64
+
65
+ module.exports.originHost = originHost;
@@ -40,6 +40,7 @@
40
40
  "provisioning.projectOwnedBy",
41
41
  "provisioning.projectPublishIdentity",
42
42
  "provisioning.selfUpdateChannel",
43
+ "provisioning.projectAppStatus",
43
44
  "provisioning.moduleCatalog"
44
45
  ],
45
46
  "consumes": []