@bongos/core 1.25.16 → 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 (93) hide show
  1. package/.bongos-core.json +200 -75
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +24 -0
  5. package/clients/bongos-client/index.cjs +24 -0
  6. package/clients/bongos-client/index.d.ts +36 -2
  7. package/clients/bongos-client/index.mjs +24 -0
  8. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  9. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  10. package/docs/api/openapi.json +761 -112
  11. package/docs/api-reference.md +23 -3
  12. package/docs/copy-inventory.md +118 -108
  13. package/docs/copy-registry.json +315 -172
  14. package/docs/file-map.md +2 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +3 -0
  17. package/docs/page-readings.json +62 -62
  18. package/modules/app-host/CLAUDE.md +15 -0
  19. package/modules/app-host/module.json +20 -0
  20. package/modules/app-host/public/settings-widget.js +143 -0
  21. package/modules/app-host/routes/app-host.js +88 -0
  22. package/modules/government/charter-advice.js +103 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/government/presets.js +42 -0
  25. package/modules/government/recommendations.js +102 -7
  26. package/modules/government/routes/government.js +1 -0
  27. package/modules/lifecycle/first-read-catalog.js +62 -0
  28. package/modules/lifecycle/first-read-db.js +190 -0
  29. package/modules/lifecycle/first-read.js +330 -0
  30. package/modules/lifecycle/kickoff-checklist.js +12 -3
  31. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  32. package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
  33. package/modules/lifecycle/module.json +2 -1
  34. package/modules/lifecycle/routes/first-read.js +101 -0
  35. package/modules/onboarding/genesis-work.js +101 -4
  36. package/modules/onboarding/routes/genesis-work.js +2 -2
  37. package/modules/platform-identity/module.json +1 -1
  38. package/modules/platform-identity/routes/sso-app-status.js +65 -0
  39. package/modules/provisioning/module.json +1 -0
  40. package/modules/provisioning/pollers/app-liveness.js +23 -1
  41. package/modules/provisioning/render-sandbox.js +113 -0
  42. package/modules/provisioning/render-standup.js +3 -1
  43. package/modules/provisioning/routes/provisioning.js +4 -1
  44. package/modules/provisioning/routes/render-standup.js +49 -1
  45. package/modules/provisioning/seams.js +32 -1
  46. package/modules/provisioning/starter-bundles.js +1 -0
  47. package/modules/public-landing/public/assets/guide-core.js +475 -0
  48. package/modules/public-landing/public/assets/guide-hall.css +121 -0
  49. package/modules/public-landing/public/assets/guide-hall.js +164 -0
  50. package/modules/public-landing/public/assets/guide-scene.css +9 -0
  51. package/modules/public-landing/public/assets/guide-scene.js +58 -381
  52. package/modules/public-landing/public/index.html +46 -1
  53. package/modules/public-landing/public/projects.html +112 -6
  54. package/modules/ui-design/kit/serve.js +1 -1
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +28 -0
  58. package/scripts/bongos/first-read.js +150 -0
  59. package/scripts/bongos/genesis.js +21 -1
  60. package/scripts/bongos/government-walk.js +4 -4
  61. package/scripts/bongos/provision-render-sandbox.js +171 -0
  62. package/scripts/bongos/provision-render.js +37 -2
  63. package/scripts/bongos/provision-teardown.js +1 -1
  64. package/scripts/bongos/publish-manifest.js +1 -0
  65. package/scripts/bongos/render-api.js +33 -3
  66. package/scripts/bongos/render-payload.js +39 -1
  67. package/scripts/bongos/seam-wiring-guard.js +3 -2
  68. package/scripts/gds/first-read.js +7 -0
  69. package/scripts/gds/provision-render-sandbox.js +6 -0
  70. package/scripts/hall-preview/server.js +2 -0
  71. package/src/bongos/auth-admission.js +64 -0
  72. package/src/bongos/auth.js +2 -1
  73. package/src/bongos/module-scope-map.js +4 -0
  74. package/src/bongos/routes/core-update.js +3 -16
  75. package/src/bongos/routes/modules.js +11 -0
  76. package/src/bongos/serve-internal.js +20 -6
  77. package/src/module-api.js +6 -1
  78. package/src/module-loader/loader.js +2 -2
  79. package/tests/app_host.mjs +413 -0
  80. package/tests/first_read.mjs +479 -0
  81. package/tests/genesis_work.mjs +74 -3
  82. package/tests/government_act_alone_buildings.mjs +2 -0
  83. package/tests/government_charter_advice.mjs +150 -0
  84. package/tests/landing_guide_hall.mjs +364 -0
  85. package/tests/landing_guide_scene.mjs +8 -5
  86. package/tests/module_api.mjs +1 -0
  87. package/tests/module_loader.mjs +1 -1
  88. package/tests/npm_release_where.mjs +13 -3
  89. package/tests/provisioning_app_status.mjs +3 -2
  90. package/tests/provisioning_render_route.mjs +1 -1
  91. package/tests/provisioning_teardown_intent.mjs +50 -0
  92. package/tests/render_api.mjs +5 -2
  93. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // modules/government/charter-advice.js — what the genesis charter stage RECOMMENDS
4
+ // from the founder's own answers (task 1005119, goal 1000106; owner feedback
5
+ // 2026-11-07: "a line of questioning that makes it easier for our system to
6
+ // recommend a form of government", and "understand the owner's principles they want
7
+ // to use to shape their bylaws").
8
+ //
9
+ // ONE ANSWER FOR EVERY PRESENTATION. The onboarding module reads the founder's
10
+ // charter answers from the genesis store and asks this, through the government
11
+ // port, for one block: the form of government to start from and why, the
12
+ // strictness preset their principles point at (or none), and a draft of the bylaws
13
+ // and code of conduct. The game frame, the hall and `npx bongos genesis show
14
+ // charter` all render this same block.
15
+ //
16
+ // NOTHING HERE DECIDES OR WRITES. The form comes from recommendations.js
17
+ // recommendedCharter, the one recommender, widened by the answers; the preset from
18
+ // presets.js recommendedPreset. Both stay recommendations: the founder picks a form
19
+ // (or Custom) through the ordinary charter amendment, and a preset only through a
20
+ // passed laws vote. The draft is words for the founder to edit and save as their
21
+ // stage page; it is never saved for them.
22
+ //
23
+ // THE PROSE AND THE LAWS AGREE. The draft's "rules our agents follow" section is the
24
+ // recommended preset's own laws, word for word, and says plainly when there are none,
25
+ // so the written bylaws cannot promise one thing while the law book does another.
26
+
27
+ const recommendations = require('./recommendations');
28
+ const presets = require('./presets');
29
+ const charter = require('./charter');
30
+
31
+ const PROSE = Object.freeze(['leave_behaviour', 'feel', 'write_down']);
32
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
33
+ const optionOf = (list, key) => list.find((o) => o.key === key) || null;
34
+
35
+ // draftBylaws({ charter, preset, prose }) → markdown, built only from what the
36
+ // founder said and what was recommended from it.
37
+ function draftBylaws({ charter: rec, preset, prose = {} }) {
38
+ const out = ['# Bylaws and code of conduct (draft)', '',
39
+ '_Drafted from your answers. Edit it freely; it becomes yours when you save it as this stage\'s page._', ''];
40
+
41
+ out.push('## How decisions are made', '');
42
+ const franchise = optionOf(charter.FRANCHISE_OPTIONS, rec.franchise);
43
+ const rule = optionOf(charter.PASS_RULE_OPTIONS, rec.pass_rule);
44
+ out.push(`This project is governed as a **${rec.name}**. ${franchise ? franchise.explanation : ''} ${rule ? `How a decision passes: ${rule.name}. ${rule.explanation}` : ''}`.replace(/\s+/g, ' ').trim(), '');
45
+
46
+ out.push('## Rules our AI agents follow', '');
47
+ if (preset) {
48
+ out.push(`These are the **${preset.name}** laws. Once the board passes them, every AI agent on the project obeys them, and a law that locks a setting is enforced on everyone's own settings. If a different preset is switched on, its laws replace this list.`, '');
49
+ for (const l of preset.laws) out.push(`- **${l.text}.** ${l.effect_on_ai}`);
50
+ out.push('');
51
+ } else {
52
+ out.push('No laws are switched on. The principles below guide people, and nothing enforces them on an agent.', '');
53
+ }
54
+
55
+ const feel = text(prose.feel);
56
+ const leave = text(prose.leave_behaviour);
57
+ if (feel || leave) {
58
+ out.push('## Code of conduct', '');
59
+ if (feel) out.push(`**What working here should feel like.** ${feel}`, '');
60
+ if (leave) out.push(`**What gets someone asked to leave.** ${leave}`, '');
61
+ }
62
+ const down = text(prose.write_down);
63
+ if (down) out.push('## Written down now, so it is not argued about later', '', down, '');
64
+ return out.join('\n').trimEnd() + '\n';
65
+ }
66
+
67
+ // buildAdvice({ answers, prose, headcount, type, laws }) — PURE. `answers` holds only
68
+ // the closed answers the founder actually gave (not "not sure yet", not skipped);
69
+ // `laws` is db.listLaws() rows, so the preset view says which is in force now.
70
+ function buildAdvice({ answers = {}, prose = {}, headcount = 0, type = null, laws = [] } = {}) {
71
+ const rec = recommendedCharter(headcount, type, answers);
72
+ const preset = presets.recommendedPreset(answers);
73
+ const view = presets.presetsView(laws);
74
+ return {
75
+ charter: rec,
76
+ // null when the founder's principles do not point at one — no preset is fine
77
+ preset: preset ? { ...preset, locks: (view.presets.find((p) => p.key === preset.key) || {}).locks || [] } : null,
78
+ preset_in_force: view.current,
79
+ preset_is: presets.PRESET_IS,
80
+ draft_md: draftBylaws({ charter: rec, preset, prose }),
81
+ headcount,
82
+ };
83
+ }
84
+
85
+ function recommendedCharter(headcount, type, answers) {
86
+ return recommendations.recommendedCharter(headcount, type, answers);
87
+ }
88
+
89
+ // charterAdvice — THE PORT'S ENTRY (government port `charterAdvice`). The head
90
+ // count is the founder's own expected number when they gave one at decide,
91
+ // otherwise the live count of active builders.
92
+ async function charterAdvice({ answers, prose, headcount = null, type = null } = {}, deps = {}) {
93
+ const db = deps.db || require('./db');
94
+ const [count, laws] = await Promise.all([
95
+ headcount != null && Number(headcount) > 0 ? Number(headcount) : db.countActiveBuilders(),
96
+ db.listLaws(),
97
+ ]);
98
+ const p = {};
99
+ for (const k of PROSE) if (prose && text(prose[k])) p[k] = text(prose[k]);
100
+ return buildAdvice({ answers, prose: p, headcount: count, type, laws });
101
+ }
102
+
103
+ module.exports = { buildAdvice, charterAdvice };
@@ -94,6 +94,7 @@ const AREAS = Object.freeze([
94
94
  'POST /goals/reorder',
95
95
  'POST /work-categories',
96
96
  'PUT /versions/focus',
97
+ 'POST /first-read/maps/:id/accept', // task 1005280: accepting a repository map files goals
97
98
  ]) }),
98
99
  Object.freeze({ key: 'automation', name: 'Automation', gate: 'autonomy.fence.manage',
99
100
  today: 'whoever may switch unattended building on and off', acts: Object.freeze([
@@ -141,8 +141,50 @@ function presetsView(rows) {
141
141
  };
142
142
  }
143
143
 
144
+ // ── which preset the founder's principles point at (task 1005119) ───────────
145
+ // The genesis charter stage asks two closed principle questions (keys held equal
146
+ // to modules/onboarding/genesis-work.js QUESTIONS.charter by test). How much people
147
+ // are trusted picks the preset; "catch it early" or "move fast" moves it one step
148
+ // stricter or looser. No answer to either: no recommendation, and that is fine — a
149
+ // project may run on written principles and no laws at all.
150
+ const PRINCIPLE_ANSWERS = Object.freeze({
151
+ trust_level: Object.freeze(['a_lot', 'some', 'little']),
152
+ catch_or_fix: Object.freeze(['catch_early', 'move_fast']),
153
+ });
154
+ const TRUST_PRESET = { a_lot: 'open', some: 'guild', little: 'factory' };
155
+ const TRUST_SAID = { a_lot: 'people should be trusted a lot to get on with it', some: 'people should be trusted some, with a few rules everyone follows', little: 'people should work under tight rules, checked often' };
156
+
157
+ // WHAT A PRESET IS, said every time one is recommended: it is not a style guide.
158
+ const PRESET_IS = "A preset is not a style guide. Switching one on is a vote that passes real laws: every AI agent on the project obeys them, and a law that locks a setting is enforced on every builder's own settings.";
159
+
160
+ // recommendedPreset(answers) → null, or { key, name, line, why, laws, is }.
161
+ function recommendedPreset(answers) {
162
+ const a = answers && typeof answers === 'object' ? answers : {};
163
+ const trust = PRINCIPLE_ANSWERS.trust_level.includes(a.trust_level) ? a.trust_level : null;
164
+ const pace = PRINCIPLE_ANSWERS.catch_or_fix.includes(a.catch_or_fix) ? a.catch_or_fix : null;
165
+ if (!trust && !pace) return null;
166
+ const order = KEYS; // open → guild → factory, loosest first
167
+ let i = trust ? order.indexOf(TRUST_PRESET[trust]) : 1;
168
+ const why = [];
169
+ if (trust) why.push(`You said ${TRUST_SAID[trust]}`);
170
+ if (pace === 'catch_early' && i < order.length - 1) { i += 1; why.push('You would rather catch problems early, so it is one step stricter' + (trust ? '' : ' than the standard set')); }
171
+ else if (pace === 'move_fast' && i > 0) { i -= 1; why.push('You would rather move fast and fix things, so it is one step looser' + (trust ? '' : ' than the standard set')); }
172
+ const p = presetOf(order[i]);
173
+ return {
174
+ key: p.key,
175
+ name: p.name,
176
+ line: p.line,
177
+ why: why.map((w) => `${w}.`).join(' '),
178
+ laws: p.laws.map((l) => ({ text: l.text, effect_on_ai: l.effect_on_ai, locks: l.locks.map((x) => ({ ...x })) })),
179
+ is: PRESET_IS,
180
+ };
181
+ }
182
+
144
183
  module.exports = {
145
184
  PRESETS,
185
+ PRINCIPLE_ANSWERS,
186
+ PRESET_IS,
187
+ recommendedPreset,
146
188
  currentPreset,
147
189
  switchProposal,
148
190
  presetsView,
@@ -156,22 +156,116 @@ const TYPE_OVERRIDES = freeze({
156
156
 
157
157
  const formByKey = (key) => charter.FORMS.find((f) => f.key === key) || null;
158
158
 
159
- function recommendedCharter(headcount, type) {
159
+ // ── what the founder said about how decisions should go (task 1005119) ────
160
+ // The genesis charter stage asks the founder a short line of questions, and the
161
+ // answers WIDEN this ladder rather than replace it: there is one recommender, so it
162
+ // can never disagree with itself. The keys are the closed choices of
163
+ // modules/onboarding/genesis-work.js QUESTIONS.charter (held equal by
164
+ // tests/government_recommendations.mjs; a module may not import a sibling, ADR
165
+ // 0083). An answer that is missing, "not sure yet" or unknown is simply not here,
166
+ // and with none at all the ladder answers exactly as it always has.
167
+ const CHARTER_ANSWERS = freeze({
168
+ final_say: freeze(['founder', 'trusted', 'everyone', 'council']),
169
+ agreement: freeze(['first_ratifier', 'consent', 'majority', 'unanimous']),
170
+ strangers: freeze(['yes', 'no']),
171
+ answers_to: freeze(['yes', 'no']),
172
+ step_back: freeze(['yes', 'no']),
173
+ speed: freeze(['fast', 'bought_in']),
174
+ });
175
+
176
+ // Each franchise makes exactly one available form (charter.js FORMS).
177
+ const FORM_OF_FRANCHISE = freeze({ founder: 'monarchy', trusted: 'timocratic-republic', everyone: 'direct-democracy', council: 'foundation' });
178
+
179
+ // The founder's own answer, said back to them.
180
+ const SAID = freeze({
181
+ final_say: freeze({ founder: 'you should have the final say', trusted: 'the people who have earned trust by shipping should have the final say',
182
+ everyone: 'everyone on the project should have the final say', council: 'a small group you appoint should have the final say' }),
183
+ agreement: freeze({ first_ratifier: 'one yes should be enough', consent: 'a decision should pass unless someone objects',
184
+ majority: 'more than half should have to say yes', unanimous: 'everyone who votes should have to agree' }),
185
+ });
186
+
187
+ function cleanAnswers(answers) {
188
+ const out = {};
189
+ if (!answers || typeof answers !== 'object') return out;
190
+ for (const [k, allowed] of Object.entries(CHARTER_ANSWERS)) {
191
+ if (allowed.includes(answers[k])) out[k] = answers[k];
192
+ }
193
+ return out;
194
+ }
195
+
196
+ const sentence = (t) => `${t[0].toUpperCase()}${t.slice(1)}.`;
197
+
198
+ // recommendedCharter(headcount, type, answers?) — the form to start from, and WHY.
199
+ // Without answers: the size ladder, unchanged. With them: the franchise follows
200
+ // who the founder said should have the final say (or what their other answers
201
+ // imply), the pass rule follows how much agreement they asked for, and `why` is
202
+ // written from their answers. Always an adoptable form; the founder still picks.
203
+ function recommendedCharter(headcount, type, answers) {
160
204
  const t = normalizeType(type);
161
205
  const size = sizeBandFor(headcount);
162
206
  const row = (TYPE_OVERRIDES[t] && TYPE_OVERRIDES[t][size.key]) || CHARTER_LADDER[size.key];
163
- const form = formByKey(row.form);
164
- const later = row.later ? formByKey(row.later) : null;
165
- const councilRank = row.form === 'foundation' ? JOB_NAMES[t].council.key : null;
207
+ const a = cleanAnswers(answers);
208
+ const ladderForm = formByKey(row.form);
209
+
210
+ const reasons = [];
211
+ let franchise = ladderForm.franchise;
212
+ let franchiseFromAnswers = true;
213
+ if (a.final_say) {
214
+ franchise = a.final_say;
215
+ reasons.push(`You said ${SAID.final_say[a.final_say]}`);
216
+ } else if (a.answers_to === 'yes') {
217
+ franchise = 'council';
218
+ reasons.push('You said the project answers to something outside it, so a council you appoint holds it to that');
219
+ } else if (a.step_back === 'yes' && franchise === 'founder') {
220
+ franchise = 'trusted';
221
+ reasons.push('You want to be able to step back later, so the say goes to builders who earn trust by shipping, and it carries on without you');
222
+ } else if (a.strangers === 'yes' && franchise === 'everyone') {
223
+ franchise = 'trusted';
224
+ reasons.push('You expect people you have not met, so a say is earned by shipping first rather than given on arrival');
225
+ } else {
226
+ franchiseFromAnswers = false;
227
+ }
228
+ const form = formByKey(FORM_OF_FRANCHISE[franchise]);
229
+
230
+ let passRule = form.pass_rule;
231
+ if (franchise === 'founder') {
232
+ if (a.agreement && a.agreement !== 'first_ratifier') {
233
+ reasons.push('With you as the only voter, your one yes is the whole vote, so the pass rule makes no difference yet');
234
+ }
235
+ } else if (a.agreement) {
236
+ passRule = a.agreement;
237
+ reasons.push(`You said ${SAID.agreement[a.agreement]}`);
238
+ } else if (a.speed === 'fast') {
239
+ reasons.push('You would rather decide fast, and "passes unless someone objects" needs no vote count, only no objection');
240
+ } else if (a.speed === 'bought_in') {
241
+ reasons.push('You would rather have everyone bought in, and "passes unless someone objects" lets anyone stop a decision they cannot live with');
242
+ }
243
+ const tuned = passRule !== form.pass_rule;
244
+ if (tuned) {
245
+ const rule = charter.PASS_RULE_OPTIONS.find((r) => r.key === passRule);
246
+ reasons.push(`So it is ${form.name} with its pass rule set to "${rule.name}", which you set in the legislation stage`);
247
+ }
248
+
249
+ const fromAnswers = reasons.length > 0;
250
+ let why = row.why;
251
+ if (fromAnswers) {
252
+ why = reasons.map(sentence).join(' ');
253
+ if (!franchiseFromAnswers) why += ` Who votes follows your size: ${row.why}.`;
254
+ }
255
+ const later = row.later && form.key === row.form ? formByKey(row.later) : null;
166
256
  return freeze({
167
- key: row.form,
257
+ key: form.key,
168
258
  name: form.name,
169
259
  rung: row.rung,
170
- why: row.why,
260
+ why,
171
261
  type: t,
172
262
  size: size.key,
173
- council_rank: councilRank,
263
+ council_rank: form.key === 'foundation' ? JOB_NAMES[t].council.key : null,
174
264
  later: later ? freeze({ key: later.key, name: later.name, explanation: later.explanation }) : null,
265
+ franchise,
266
+ pass_rule: passRule,
267
+ pass_rule_tuned: tuned,
268
+ from_answers: fromAnswers,
175
269
  });
176
270
  }
177
271
 
@@ -222,6 +316,7 @@ function recommendationsFor({ type, headcount } = {}) {
222
316
 
223
317
  module.exports = {
224
318
  PROJECT_TYPES,
319
+ CHARTER_ANSWERS,
225
320
  JOB_GRANTS,
226
321
  JOB_CAN,
227
322
  sizeBandFor,
@@ -138,6 +138,7 @@ const governmentPort = {
138
138
  // home, which renders the stages from GET /government/constitution's
139
139
  // `charter` block. Opening a sitting decides nothing; the vote does.
140
140
  proposeCharter: board.proposeCharter,
141
+ charterAdvice: (input) => require('../charter-advice').charterAdvice(input), // task 1005119: advice only, never a write
141
142
  genesisStageStatus: board.genesisStageStatus,
142
143
  // task 1004529 — who decides what. The table, resolved, and the check a
143
144
  // building's own direct route runs before letting one builder act alone.
@@ -0,0 +1,62 @@
1
+ // modules/lifecycle/first-read-catalog.js — the module list the first read-through may
2
+ // recommend from (task 1005281, goal 1000106): the modules THIS project can switch on
3
+ // and has not, read live on every call.
4
+ //
5
+ // WHY LIVE, AND WHY ONLY THIS. A recommendation names a module the owner can actually
6
+ // switch on, so the list is the project's own (the kernel's `modules.catalog` port, the
7
+ // same listing the Modules page shows), minus what is already on. Where the hub's
8
+ // project-creation picker is mounted (`provisioning.moduleCatalog`), its plain words
9
+ // replace a module's own description; the keys still come from this project. The owner's
10
+ // AI session gets the list in its prompt and the server checks every recommended key
11
+ // against it on submit, so a recommendation can never name a module that is not there.
12
+ // Nothing here reads a repository or calls an AI.
13
+
14
+ 'use strict';
15
+
16
+ const api = require('../../src/module-api');
17
+
18
+ const SUMMARY_MAX = 200;
19
+
20
+ // First sentence, capped: a manifest description is written for engineers and long.
21
+ function shortWords(text) {
22
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
23
+ const first = (s.match(/^.*?[.!?](\s|$)/) || [s])[0].trim();
24
+ return first.length > SUMMARY_MAX ? first.slice(0, SUMMARY_MAX - 1).trimEnd() + '…' : first;
25
+ }
26
+
27
+ // buildCatalog(listed, picker) → [{ key, title, summary }] sorted by key. Pure.
28
+ // listed the `modules.catalog` port's answer: [{ key, title, description, enabled }]
29
+ // picker optional `provisioning.moduleCatalog` answer: { optional: [{ key, label, summary }] }
30
+ function buildCatalog(listed, picker) {
31
+ const words = new Map(((picker && picker.optional) || []).filter((m) => m && m.key).map((m) => [m.key, m]));
32
+ return (Array.isArray(listed) ? listed : [])
33
+ .filter((m) => m && typeof m.key === 'string' && m.key && m.enabled !== true)
34
+ .map((m) => {
35
+ const w = words.get(m.key);
36
+ return {
37
+ key: m.key,
38
+ title: String((w && w.label) || m.title || m.key),
39
+ summary: shortWords((w && w.summary) || m.description),
40
+ };
41
+ })
42
+ .sort((a, b) => a.key.localeCompare(b.key));
43
+ }
44
+
45
+ // switchOnCatalog() → [{ key, title, summary }] | null. null means the list could not be
46
+ // read here; the submit then refuses any recommendation rather than trusting one.
47
+ async function switchOnCatalog() {
48
+ const listed = api.resolveOptional('modules.catalog');
49
+ if (typeof listed !== 'function') return null;
50
+ try {
51
+ const picker = api.resolveOptional('provisioning.moduleCatalog');
52
+ let pickerAnswer = null;
53
+ if (typeof picker === 'function') {
54
+ try { pickerAnswer = await picker(); } catch { pickerAnswer = null; }
55
+ }
56
+ return buildCatalog(await listed(), pickerAnswer);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ module.exports = { buildCatalog, switchOnCatalog };
@@ -0,0 +1,190 @@
1
+ // modules/lifecycle/first-read-db.js — the store behind the first read-through (task
2
+ // 1005280): keep a submitted repository map, show it for review with what is already
3
+ // filed, and file the owner's accepted items as DRAFT goals and tasks.
4
+ //
5
+ // The rules live in first-read.js (pure); this file only runs them against Postgres.
6
+ // Two properties an edit must keep:
7
+ // - SUBMIT FILES NOTHING. A map is stored `pending`; only acceptMap creates goals or
8
+ // tasks, and only for the refs the owner named (or 'all').
9
+ // - A RE-RUN NEVER DUPLICATES. Every filed ref is a row in lifecycle_repo_map_filed
10
+ // (PRIMARY KEY), read under an advisory lock inside the accept transaction, so two
11
+ // accepts — or a second map naming the same item — file it once.
12
+ // Nothing here calls an AI: the map arrives already written by the owner's own session.
13
+
14
+ 'use strict';
15
+
16
+ const api = require('../../src/module-api');
17
+ const db = require('./db');
18
+ const fr = require('./first-read');
19
+ const { FIRST_VERSION_SQL } = require('./kickoff-seed');
20
+
21
+ const TASK_SOURCE = 'repo-map';
22
+ const ACCEPT_LOCK = "pg_advisory_xact_lock(hashtext('first-read/accept'))";
23
+
24
+ const poolOf = (deps) => deps.pool || api.pool;
25
+
26
+ // submitMap({ raw, builderId, catalog }) → { ok: true, map_id, items } | { ok: false, errors }
27
+ // Validates, then stores the NORMALISED map (unknown sections kept). Files nothing.
28
+ // `catalog` is the live module list (first-read-catalog.js; null when unreadable): every
29
+ // recommended module must be on it, and its words are the catalog's, not the session's.
30
+ async function submitMap({ raw, builderId = null, catalog = null }, deps = {}) {
31
+ const v = fr.validateRepoMap(raw, { catalog });
32
+ if (!v.ok) return v;
33
+ const { rows: [row] } = await poolOf(deps).query(
34
+ `INSERT INTO lifecycle_repo_maps (schema, map, submitted_by) VALUES ($1, $2::jsonb, $3) RETURNING id, status, submitted_at`,
35
+ [fr.SCHEMA, JSON.stringify(v.map), builderId],
36
+ );
37
+ return { ok: true, map_id: String(row.id), status: row.status, items: v.items };
38
+ }
39
+
40
+ // filedRefs(refs) → Map(ref → { kind, entity_id }) for the refs already filed.
41
+ async function filedRefs(refs, exec) {
42
+ if (!refs.length) return new Map();
43
+ const { rows } = await exec.query(
44
+ 'SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed WHERE ref = ANY($1::text[])', [refs],
45
+ );
46
+ return new Map(rows.map((r) => [r.ref, { kind: r.kind, entity_id: r.entity_id == null ? null : String(r.entity_id) }]));
47
+ }
48
+
49
+ // reviewMap(id | 'latest') → { map_id, status, summary, items: [{ ...item, filed }], extra } | null
50
+ // What the owner reads before deciding: every item with whether it is already in Bongos.
51
+ async function reviewMap(id, deps = {}) {
52
+ const pool = poolOf(deps);
53
+ const { rows: [row] } = id === 'latest'
54
+ ? await pool.query('SELECT id, status, map, submitted_at FROM lifecycle_repo_maps ORDER BY submitted_at DESC, id DESC LIMIT 1')
55
+ : await pool.query('SELECT id, status, map, submitted_at FROM lifecycle_repo_maps WHERE id = $1', [id]);
56
+ if (!row) return null;
57
+ const v = fr.validateRepoMap(row.map);
58
+ const items = v.ok ? v.items : [];
59
+ const filed = await filedRefs(items.map((i) => i.ref), pool);
60
+ const extra = {};
61
+ for (const [k, val] of Object.entries(row.map || {})) if (!['schema', 'summary', 'goals', 'next', 'recommended_modules'].includes(k)) extra[k] = val;
62
+ return {
63
+ map_id: String(row.id), status: row.status, submitted_at: row.submitted_at,
64
+ summary: (row.map && row.map.summary) || '',
65
+ items: items.map((i) => ({ ...i, filed: filed.has(i.ref) ? filed.get(i.ref) : null })),
66
+ extra,
67
+ };
68
+ }
69
+
70
+ // acceptMap({ mapId, select, builderId }) → { ok, filed: { goals, tasks }, skipped } | { ok: false, code, status, ... }
71
+ // ONE transaction: lock the map, lock the accept path, plan against what is already
72
+ // filed, create the drafts, remember their refs, mark the map accepted. A refusal files
73
+ // nothing. Goals are created open; tasks at `backlog` (drafts nobody can claim yet).
74
+ async function acceptMap({ mapId, select, builderId = null }, deps = {}) {
75
+ if (select !== 'all' && (!Array.isArray(select) || select.length === 0)) {
76
+ return { ok: false, code: 'nothing_selected', status: 400 };
77
+ }
78
+ const createGoal = deps.createGoal || ((row, d) => db.createGoal(row, d));
79
+ const createTask = deps.createTask || ((row, d) => db.createTask(row, d));
80
+ const client = await poolOf(deps).connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+ const { rows: [row] } = await client.query('SELECT id, status, map FROM lifecycle_repo_maps WHERE id = $1 FOR UPDATE', [mapId]);
84
+ if (!row) { await client.query('ROLLBACK'); return { ok: false, code: 'not_found', status: 404 }; }
85
+ if (row.status === 'rejected') { await client.query('ROLLBACK'); return { ok: false, code: 'map_rejected', status: 409 }; }
86
+ await client.query(`SELECT ${ACCEPT_LOCK}`);
87
+ const v = fr.validateRepoMap(row.map);
88
+ if (!v.ok) { await client.query('ROLLBACK'); return { ok: false, code: 'invalid_map', status: 409, errors: v.errors }; }
89
+ const goalRefs = [...new Set([...v.items.map((i) => i.ref), ...v.items.map((i) => i.goal_ref).filter(Boolean)])];
90
+ const known = await filedRefs(goalRefs, client);
91
+ // A dismissed item is not filed: 'all' passes it by, naming it files it (planAcceptance).
92
+ const already = new Map([...known].filter(([, f]) => f.kind !== 'dismissed'));
93
+ const dismissed = new Set([...known].filter(([, f]) => f.kind === 'dismissed').map(([ref]) => ref));
94
+ const plan = fr.planAcceptance({ items: v.items, mapId: String(row.id), select, filedRefs: new Set(already.keys()), dismissedRefs: dismissed });
95
+ if (plan.unknown.length) { await client.query('ROLLBACK'); return { ok: false, code: 'unknown_items', status: 400, unknown: plan.unknown }; }
96
+
97
+ let versionId = null;
98
+ if (plan.goals.length || plan.tasks.length) {
99
+ const { rows: versions } = await client.query(FIRST_VERSION_SQL);
100
+ if (!versions.length) { await client.query('ROLLBACK'); return { ok: false, code: 'no_version', status: 409 }; }
101
+ versionId = versions[0].id;
102
+ }
103
+ // The filed refs are written in ONE statement after the creates, not one per item.
104
+ const remembered = [];
105
+ const remember = (ref, kind, id) => { remembered.push([ref, kind, String(id)]); };
106
+ const goalIds = new Map([...already].filter(([, f]) => f.kind === 'goal').map(([ref, f]) => [ref, f.entity_id]));
107
+ const filed = { goals: [], tasks: [] };
108
+ for (const g of plan.goals) {
109
+ const goal = await createGoal({ versionId, title: g.title, description: g.description, createdBy: builderId }, { client });
110
+ goalIds.set(g.ref, String(goal.id));
111
+ remember(g.ref, 'goal', goal.id);
112
+ filed.goals.push({ ref: g.ref, id: String(goal.id), title: g.title });
113
+ }
114
+ for (const t of plan.tasks) {
115
+ const task = await createTask({
116
+ versionId, title: t.title, description: t.description, status: 'backlog', kind: t.kind,
117
+ discipline: 'artisan', source: TASK_SOURCE, sourceRef: t.ref, goalId: goalIds.get(t.goalRef), createdBy: builderId,
118
+ }, { client });
119
+ remember(t.ref, 'task', task.id);
120
+ filed.tasks.push({ ref: t.ref, id: String(task.id), title: t.title, goal_id: goalIds.get(t.goalRef) });
121
+ }
122
+ if (remembered.length) {
123
+ await client.query(
124
+ `INSERT INTO lifecycle_repo_map_filed (ref, kind, entity_id, map_id, filed_by)
125
+ SELECT r, k, e, $4, $5 FROM unnest($1::text[], $2::text[], $3::bigint[]) AS u(r, k, e)
126
+ ON CONFLICT (ref) DO UPDATE SET kind = EXCLUDED.kind, entity_id = EXCLUDED.entity_id,
127
+ map_id = EXCLUDED.map_id, filed_by = EXCLUDED.filed_by, filed_at = now()
128
+ WHERE lifecycle_repo_map_filed.kind = 'dismissed'`,
129
+ [remembered.map((x) => x[0]), remembered.map((x) => x[1]), remembered.map((x) => x[2]), row.id, builderId],
130
+ );
131
+ }
132
+ await client.query(
133
+ `UPDATE lifecycle_repo_maps SET status = 'accepted', reviewed_by = $2, reviewed_at = now() WHERE id = $1`,
134
+ [row.id, builderId],
135
+ );
136
+ await client.query('COMMIT');
137
+ return { ok: true, map_id: String(row.id), filed, skipped: plan.skipped };
138
+ } catch (e) {
139
+ await client.query('ROLLBACK').catch(() => {});
140
+ throw e;
141
+ } finally {
142
+ client.release();
143
+ }
144
+ }
145
+
146
+ // rejectMap({ mapId, builderId }) → { ok } | { ok: false, code, status }. Files nothing.
147
+ // Only a pending map can be rejected: an accepted one already has drafts the owner said yes to.
148
+ async function rejectMap({ mapId, builderId = null }, deps = {}) {
149
+ const { rows } = await poolOf(deps).query(
150
+ `UPDATE lifecycle_repo_maps SET status = 'rejected', reviewed_by = $2, reviewed_at = now()
151
+ WHERE id = $1 AND status = 'pending' RETURNING id`,
152
+ [mapId, builderId],
153
+ );
154
+ if (rows.length) return { ok: true, map_id: String(rows[0].id) };
155
+ const { rows: [row] } = await poolOf(deps).query('SELECT status FROM lifecycle_repo_maps WHERE id = $1', [mapId]);
156
+ if (!row) return { ok: false, code: 'not_found', status: 404 };
157
+ return { ok: false, code: 'not_pending', status: 409, current: row.status };
158
+ }
159
+
160
+ // dismissItems({ mapId, refs, builderId }) → { ok, dismissed, skipped } | { ok: false, code, status, ... }
161
+ // The owner's no to named items (a module recommendation, usually). Files nothing; it
162
+ // records the no, so a later "accept all" passes the item by. An item already filed is
163
+ // left as it is (skipped), and a ref the map does not hold is refused.
164
+ async function dismissItems({ mapId, refs, builderId = null }, deps = {}) {
165
+ if (!Array.isArray(refs) || !refs.length) return { ok: false, code: 'nothing_selected', status: 400 };
166
+ const pool = poolOf(deps);
167
+ const { rows: [row] } = await pool.query('SELECT id, status, map FROM lifecycle_repo_maps WHERE id = $1', [mapId]);
168
+ if (!row) return { ok: false, code: 'not_found', status: 404 };
169
+ if (row.status === 'rejected') return { ok: false, code: 'map_rejected', status: 409 };
170
+ const v = fr.validateRepoMap(row.map);
171
+ if (!v.ok) return { ok: false, code: 'invalid_map', status: 409, errors: v.errors };
172
+ const held = new Set(v.items.map((i) => i.ref));
173
+ const wanted = [...new Set(refs)];
174
+ const unknown = wanted.filter((r) => !held.has(r));
175
+ if (unknown.length) return { ok: false, code: 'unknown_items', status: 400, unknown };
176
+ const known = await filedRefs(wanted, pool);
177
+ const skipped = wanted.filter((r) => known.has(r) && known.get(r).kind !== 'dismissed').map((ref) => ({ ref, reason: 'already_filed' }));
178
+ const fresh = wanted.filter((r) => !known.has(r));
179
+ if (fresh.length) {
180
+ await pool.query(
181
+ `INSERT INTO lifecycle_repo_map_filed (ref, kind, entity_id, map_id, filed_by)
182
+ SELECT r, 'dismissed', NULL, $2, $3 FROM unnest($1::text[]) AS u(r)
183
+ ON CONFLICT (ref) DO NOTHING`,
184
+ [fresh, row.id, builderId],
185
+ );
186
+ }
187
+ return { ok: true, map_id: String(row.id), dismissed: wanted.filter((r) => !skipped.some((x) => x.ref === r)), skipped };
188
+ }
189
+
190
+ module.exports = { TASK_SOURCE, submitMap, reviewMap, acceptMap, rejectMap, dismissItems };