@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.
- package/.bongos-core.json +131 -61
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +15 -1
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +243 -6
- package/docs/api-reference.md +12 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +47 -9
- package/modules/lifecycle/first-read.js +88 -23
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/routes/first-read.js +27 -5
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +11 -2
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/first-read.js +45 -11
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +184 -8
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
|
@@ -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 };
|
|
@@ -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
|
-
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
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:
|
|
257
|
+
key: form.key,
|
|
168
258
|
name: form.name,
|
|
169
259
|
rung: row.rung,
|
|
170
|
-
why
|
|
260
|
+
why,
|
|
171
261
|
type: t,
|
|
172
262
|
size: size.key,
|
|
173
|
-
council_rank:
|
|
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 };
|
|
@@ -23,10 +23,12 @@ const ACCEPT_LOCK = "pg_advisory_xact_lock(hashtext('first-read/accept'))";
|
|
|
23
23
|
|
|
24
24
|
const poolOf = (deps) => deps.pool || api.pool;
|
|
25
25
|
|
|
26
|
-
// submitMap({ raw, builderId }) → { ok: true, map_id, items } | { ok: false, errors }
|
|
26
|
+
// submitMap({ raw, builderId, catalog }) → { ok: true, map_id, items } | { ok: false, errors }
|
|
27
27
|
// Validates, then stores the NORMALISED map (unknown sections kept). Files nothing.
|
|
28
|
-
|
|
29
|
-
|
|
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 });
|
|
30
32
|
if (!v.ok) return v;
|
|
31
33
|
const { rows: [row] } = await poolOf(deps).query(
|
|
32
34
|
`INSERT INTO lifecycle_repo_maps (schema, map, submitted_by) VALUES ($1, $2::jsonb, $3) RETURNING id, status, submitted_at`,
|
|
@@ -41,7 +43,7 @@ async function filedRefs(refs, exec) {
|
|
|
41
43
|
const { rows } = await exec.query(
|
|
42
44
|
'SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed WHERE ref = ANY($1::text[])', [refs],
|
|
43
45
|
);
|
|
44
|
-
return new Map(rows.map((r) => [r.ref, { kind: r.kind, entity_id: String(r.entity_id) }]));
|
|
46
|
+
return new Map(rows.map((r) => [r.ref, { kind: r.kind, entity_id: r.entity_id == null ? null : String(r.entity_id) }]));
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// reviewMap(id | 'latest') → { map_id, status, summary, items: [{ ...item, filed }], extra } | null
|
|
@@ -56,7 +58,7 @@ async function reviewMap(id, deps = {}) {
|
|
|
56
58
|
const items = v.ok ? v.items : [];
|
|
57
59
|
const filed = await filedRefs(items.map((i) => i.ref), pool);
|
|
58
60
|
const extra = {};
|
|
59
|
-
for (const [k, val] of Object.entries(row.map || {})) if (!['schema', 'summary', 'goals', 'next'].includes(k)) extra[k] = val;
|
|
61
|
+
for (const [k, val] of Object.entries(row.map || {})) if (!['schema', 'summary', 'goals', 'next', 'recommended_modules'].includes(k)) extra[k] = val;
|
|
60
62
|
return {
|
|
61
63
|
map_id: String(row.id), status: row.status, submitted_at: row.submitted_at,
|
|
62
64
|
summary: (row.map && row.map.summary) || '',
|
|
@@ -85,8 +87,11 @@ async function acceptMap({ mapId, select, builderId = null }, deps = {}) {
|
|
|
85
87
|
const v = fr.validateRepoMap(row.map);
|
|
86
88
|
if (!v.ok) { await client.query('ROLLBACK'); return { ok: false, code: 'invalid_map', status: 409, errors: v.errors }; }
|
|
87
89
|
const goalRefs = [...new Set([...v.items.map((i) => i.ref), ...v.items.map((i) => i.goal_ref).filter(Boolean)])];
|
|
88
|
-
const
|
|
89
|
-
|
|
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 });
|
|
90
95
|
if (plan.unknown.length) { await client.query('ROLLBACK'); return { ok: false, code: 'unknown_items', status: 400, unknown: plan.unknown }; }
|
|
91
96
|
|
|
92
97
|
let versionId = null;
|
|
@@ -117,7 +122,10 @@ async function acceptMap({ mapId, select, builderId = null }, deps = {}) {
|
|
|
117
122
|
if (remembered.length) {
|
|
118
123
|
await client.query(
|
|
119
124
|
`INSERT INTO lifecycle_repo_map_filed (ref, kind, entity_id, map_id, filed_by)
|
|
120
|
-
SELECT r, k, e, $4, $5 FROM unnest($1::text[], $2::text[], $3::bigint[]) AS u(r, k, e)
|
|
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'`,
|
|
121
129
|
[remembered.map((x) => x[0]), remembered.map((x) => x[1]), remembered.map((x) => x[2]), row.id, builderId],
|
|
122
130
|
);
|
|
123
131
|
}
|
|
@@ -149,4 +157,34 @@ async function rejectMap({ mapId, builderId = null }, deps = {}) {
|
|
|
149
157
|
return { ok: false, code: 'not_pending', status: 409, current: row.status };
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
|
|
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 };
|