@bongos/core 1.19.512 → 1.19.514
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 +77 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0182-project-visibility-two-axes.md +2 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +41 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/projects_hub.mjs +5 -1
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/visible_projects_feed.mjs +320 -0
- package/tests/watch_applications_queue.mjs +381 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// modules/platform-identity/routes/projects.js — the hub projects catalog + the
|
|
2
2
|
// curated public Explore showcase (ADR 0141 §5, task #1002110).
|
|
3
3
|
//
|
|
4
|
-
// GET /projects/featured — PUBLIC
|
|
4
|
+
// GET /projects/featured — PUBLIC projects feed: every project on the map
|
|
5
|
+
// (active + publishable), each carrying its `state`
|
|
6
|
+
// and `orb` render type, stealth rows anonymised
|
|
7
|
+
// server-side, dark matter excluded (ADR 0182 D2a/D3).
|
|
8
|
+
// The PATH keeps the old word: it is a rank:public,
|
|
9
|
+
// CORS-open URL that a static site and other origins
|
|
10
|
+
// already fetch, and renaming it would break them for
|
|
11
|
+
// nothing. `featured` now only RANKS the feed.
|
|
5
12
|
// GET /projects — Archon: full curation list (+ featured flags, the stored
|
|
6
13
|
// join door, and the DERIVED publish verdict — ADR 0182)
|
|
7
14
|
// POST /projects — Archon: create / update a catalog entry
|
|
@@ -141,13 +148,20 @@ module.exports = function projectsRoutes() {
|
|
|
141
148
|
}
|
|
142
149
|
});
|
|
143
150
|
|
|
144
|
-
// The PUBLIC
|
|
145
|
-
// CORS-enabled cross-origin read (a status page / the apex
|
|
151
|
+
// The PUBLIC projects feed — every project ON THE MAP (active + publishable),
|
|
152
|
+
// public projection. CORS-enabled cross-origin read (a status page / the apex /
|
|
153
|
+
// the static orbs site fetches it pre-auth).
|
|
146
154
|
router.use('/projects/featured', corsPublicGet('/projects/featured'));
|
|
147
|
-
// rank: public —
|
|
155
|
+
// rank: public — the public map of projects; no auth by design.
|
|
148
156
|
router.get('/projects/featured', async (_req, res) => {
|
|
149
157
|
try {
|
|
150
|
-
const projects = await pi.
|
|
158
|
+
const projects = await pi.listPublicProjects({ pool });
|
|
159
|
+
// A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
|
|
160
|
+
// is kept out of both enrichments below: asking its own public API for its
|
|
161
|
+
// builders and their avatars would put identity on the wire, and tell that
|
|
162
|
+
// project it was being enumerated. feedShapeFor() redacts the row either
|
|
163
|
+
// way — this is the half that never makes the request at all.
|
|
164
|
+
const identified = projects.filter((p) => !pi.isStealthProject(p));
|
|
151
165
|
// Builders + tags come from each project's OWN public API, because the
|
|
152
166
|
// apex catalog does not know them and the membership tables would report
|
|
153
167
|
// zero for every hand-wired project (task 1002771; see project-stats.js).
|
|
@@ -156,7 +170,7 @@ module.exports = function projectsRoutes() {
|
|
|
156
170
|
// listing — a broken stats fetch cannot take the catalog down with it.
|
|
157
171
|
let stats = {};
|
|
158
172
|
try {
|
|
159
|
-
stats = await projectStats.getProjectStats(
|
|
173
|
+
stats = await projectStats.getProjectStats(identified.map((p) => p.origin));
|
|
160
174
|
} catch (err) {
|
|
161
175
|
console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
|
|
162
176
|
}
|
|
@@ -168,7 +182,7 @@ module.exports = function projectsRoutes() {
|
|
|
168
182
|
// a failed read leaves every project on the platform's own pick.
|
|
169
183
|
let planets = {};
|
|
170
184
|
try {
|
|
171
|
-
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(
|
|
185
|
+
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(identified.map((p) => hostOfOrigin(p.origin)));
|
|
172
186
|
} catch (err) {
|
|
173
187
|
log.warn('project planets enrichment failed (non-fatal):', err.message);
|
|
174
188
|
}
|
|
@@ -183,8 +197,13 @@ module.exports = function projectsRoutes() {
|
|
|
183
197
|
// catalog's; a project that did not answer carries the ONE shared null
|
|
184
198
|
// shape, so the wire never drops a key. art_url is catalog text an Archon
|
|
185
199
|
// typed — scheme-checked here, and again on the client, before it is a src.
|
|
200
|
+
// feedShapeFor() is the LAST thing each row passes through, deliberately: it
|
|
201
|
+
// derives `state` + `orb` from the stored door and, for a stealth project,
|
|
202
|
+
// rebuilds the row from an allow-list of the two keys a black hole may carry
|
|
203
|
+
// (ADR 0182 D2a). Redacting any earlier would be silently undone by the
|
|
204
|
+
// spreads above.
|
|
186
205
|
res.json({
|
|
187
|
-
projects: projects.map((p) => ({
|
|
206
|
+
projects: projects.map((p) => pi.feedShapeFor({
|
|
188
207
|
...p,
|
|
189
208
|
art_url: projectStats.httpUrl(p.art_url, 2048),
|
|
190
209
|
...(stats[p.origin] || projectStats.NULL_STATS),
|
|
@@ -261,17 +261,21 @@ await ta('upsertProject carries the planet template, COALESCE-preserved; an unkn
|
|
|
261
261
|
for (const t of P.PROJECT_TEMPLATES.slice(0, 9)) assert.ok(mig12.includes(`'${t}'`), `migration 012 named ${t}`);
|
|
262
262
|
// and the public projection carries it, so the sky can read a curated choice
|
|
263
263
|
const pool3 = mockPool([{ rows: [] }]);
|
|
264
|
-
await P.
|
|
264
|
+
await P.listPublicProjects({ pool: pool3 });
|
|
265
265
|
const sel = (pool3.calls[0].text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
|
|
266
|
-
assert.match(sel, /\btemplate\b/, 'the
|
|
266
|
+
assert.match(sel, /\btemplate\b/, 'the public projection carries the template');
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
await ta('
|
|
269
|
+
await ta('listPublicProjects returns a PUBLIC projection (on-the-map only, no owner/status)', async () => {
|
|
270
270
|
const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
|
|
271
|
-
const out = await P.
|
|
271
|
+
const out = await P.listPublicProjects({ pool });
|
|
272
272
|
assert.equal(out.length, 1);
|
|
273
273
|
const c = pool.calls[0];
|
|
274
|
-
|
|
274
|
+
// R06 (task 1002325) replaced the `featured = true` gate with the derived publish
|
|
275
|
+
// gate: a project is on the map iff it is active AND publishable (ADR 0182 D3).
|
|
276
|
+
// The full predicate + the stealth redaction are pinned by tests/visible_projects_feed.mjs.
|
|
277
|
+
assert.match(c.text, /WHERE status = 'active' AND /);
|
|
278
|
+
assert.ok(c.text.includes(P.publishableSql()), 'the map predicate is the shared gate');
|
|
275
279
|
// Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
|
|
276
280
|
// not satisfy a projection guard, and SELECT * must never sneak owner/status
|
|
277
281
|
// onto a CORS-open rank:public route.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// modules/provisioning/recommendations.js — the RULE ENGINE that turns what an
|
|
2
|
+
// owner said during project creation into a recommended starter bundle, a
|
|
3
|
+
// recommended governance preset, and an invite suggestion (BV1.R17, task
|
|
4
|
+
// 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
|
|
5
|
+
// `proj-invite-builders`).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Goal 1000046 pins the near term as RULE-BASED and defers the semantic
|
|
8
|
+
// layer: "near-term recommendations are rule-based and module search is lexical;
|
|
9
|
+
// the AI/embeddings layer … is a deferred follow-up captured in the idea inbox"
|
|
10
|
+
// (idea 1000552, task 1003500). So this file is a set of small declarative
|
|
11
|
+
// TABLES — condition -> effect -> a one-line `reason` in the owner's own words —
|
|
12
|
+
// and a resolver over them. Not a model, not a scoring function, and nothing
|
|
13
|
+
// that reads the free-text description: a rule an owner cannot be told the
|
|
14
|
+
// reason for is a recommendation they cannot audit.
|
|
15
|
+
//
|
|
16
|
+
// TWO INPUTS, AND ONLY TWO. `type` is the DECLARED project type (a column, the
|
|
17
|
+
// closed set task 1002772 fixed) and `team_shape` is the one closed-answer key
|
|
18
|
+
// the R15 interview collects (`PROJECT_DETAIL_VOCAB` in provisioning.js beside
|
|
19
|
+
// this file). The interview deliberately does not re-ask the type (ADR 0237 §2),
|
|
20
|
+
// so the two arrive separately and this is the file that puts them together.
|
|
21
|
+
//
|
|
22
|
+
// SKIPPING IS A SUPPORTED ANSWER, NOT A GAP. The interview is optional (ADR 0182
|
|
23
|
+
// D4), so every table has a stated DEFAULT for "nobody said" and the defaults are
|
|
24
|
+
// deliberately the narrowest option — the type's own bundle unadjusted, the
|
|
25
|
+
// governance preset that grants nobody anything, and no invites. An off-vocabulary
|
|
26
|
+
// team shape reads as no answer for the same reason `normalizeProjectDetail` drops
|
|
27
|
+
// one: inventing an answer nobody gave feeds the recommendation a lie. R18 (task
|
|
28
|
+
// 1002337) is the live proof of exactly that split.
|
|
29
|
+
//
|
|
30
|
+
// PURE — no I/O, no core import, no sibling-module import; requires only its own
|
|
31
|
+
// module's `starter-bundles.js` and `provisioning.js`, both of which are pure
|
|
32
|
+
// themselves. That is what lets a route, a script and a test read one answer.
|
|
33
|
+
// It is also why `invites.suggested` is always empty here: ranking builders means
|
|
34
|
+
// querying the leaderboard, which lives in a sibling module (`economy`) this one
|
|
35
|
+
// may not import (the one-way rule, ADR 0083). The engine states WHAT to look for
|
|
36
|
+
// in `invites.seeking`; R28 (task 1002347) owns the lookup that fills it.
|
|
37
|
+
//
|
|
38
|
+
// Decision record: ADR 0239.
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { bundleForProjectType, ALWAYS_ON_CORE } = require('./starter-bundles');
|
|
43
|
+
const { PROJECT_DETAIL_VOCAB } = require('./provisioning');
|
|
44
|
+
|
|
45
|
+
// How the team shape adjusts the type's starter bundle, in application order.
|
|
46
|
+
// Each entry is one condition (`teamShape`) and one effect on ONE opt-in module,
|
|
47
|
+
// carrying the sentence a picker shows when it fires. Deliberately tiny: ADR 0237
|
|
48
|
+
// already made the per-type preset the opinion, and today's opt-in catalog is
|
|
49
|
+
// three modules wide (`agents`, `dev-box`, `discord`) — so this table's job is to
|
|
50
|
+
// correct a preset the owner's answer contradicts, not to re-derive it.
|
|
51
|
+
//
|
|
52
|
+
// A rule may only ever name an OPT-IN module. Adding or removing an always-on one
|
|
53
|
+
// would be a toggle that toggles nothing (ADR 0237 §3's argument, and
|
|
54
|
+
// `tests/provisioning_recommendations.mjs` fails the build over it).
|
|
55
|
+
const TEAM_SHAPE_RULES = Object.freeze([
|
|
56
|
+
Object.freeze({
|
|
57
|
+
id: 'a-community-needs-somewhere-to-gather',
|
|
58
|
+
teamShape: 'community',
|
|
59
|
+
effect: 'add',
|
|
60
|
+
module: 'discord',
|
|
61
|
+
reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
|
|
62
|
+
}),
|
|
63
|
+
Object.freeze({
|
|
64
|
+
id: 'a-team-shares-one-environment',
|
|
65
|
+
teamShape: 'small-team',
|
|
66
|
+
effect: 'add',
|
|
67
|
+
module: 'dev-box',
|
|
68
|
+
reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
|
|
69
|
+
}),
|
|
70
|
+
Object.freeze({
|
|
71
|
+
id: 'building-alone-needs-no-shared-environment',
|
|
72
|
+
teamShape: 'solo',
|
|
73
|
+
effect: 'remove',
|
|
74
|
+
module: 'dev-box',
|
|
75
|
+
reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// The governance presets, by key. A preset is a NAME plus the one sentence that
|
|
80
|
+
// says what living under it feels like — never a set of roles or permissions,
|
|
81
|
+
// because there is nowhere to put them: the governance module is planned (goal
|
|
82
|
+
// 1000030) and unbuilt, which criterion `proj-governance-reco` says out loud.
|
|
83
|
+
// R25 (task 1002344) is the spike that fits these to the real module when it
|
|
84
|
+
// exists; until then every recommendation carries `applied: false` so a form can
|
|
85
|
+
// label it a preview without hardcoding that fact in four places.
|
|
86
|
+
const GOVERNANCE_PRESETS = Object.freeze({
|
|
87
|
+
'owner-only': Object.freeze({
|
|
88
|
+
label: 'You decide',
|
|
89
|
+
preview: 'You hold every role. Nothing waits on anyone else, and nothing changes without you.',
|
|
90
|
+
}),
|
|
91
|
+
'trusted-team': Object.freeze({
|
|
92
|
+
label: 'A trusted team',
|
|
93
|
+
preview: 'Everyone you invite can build and ship. You keep the decisions that spend money or change the shape of the project.',
|
|
94
|
+
}),
|
|
95
|
+
'open-contribution': Object.freeze({
|
|
96
|
+
label: 'Open contribution',
|
|
97
|
+
preview: 'Anyone can turn up and propose work. A small circle you choose reviews what actually lands.',
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Team shape -> governance preset. One row per shape the interview can return;
|
|
102
|
+
// the integrity test fails the build if a shape in `PROJECT_DETAIL_VOCAB` has no
|
|
103
|
+
// row here, so a fourth answer can never be added and silently unhandled.
|
|
104
|
+
const GOVERNANCE_RULES = Object.freeze([
|
|
105
|
+
Object.freeze({
|
|
106
|
+
teamShape: 'solo',
|
|
107
|
+
preset: 'owner-only',
|
|
108
|
+
reason: 'You said you are building this alone.',
|
|
109
|
+
}),
|
|
110
|
+
Object.freeze({
|
|
111
|
+
teamShape: 'small-team',
|
|
112
|
+
preset: 'trusted-team',
|
|
113
|
+
reason: 'You said a small team is building this, so the people you invite can get on with it without asking first.',
|
|
114
|
+
}),
|
|
115
|
+
Object.freeze({
|
|
116
|
+
teamShape: 'community',
|
|
117
|
+
preset: 'open-contribution',
|
|
118
|
+
reason: 'You said an open community is building this, so people can arrive and propose work without an invite.',
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// What a skipped interview gets. The narrowest preset on purpose: widening later
|
|
123
|
+
// costs one setting, while a project that quietly granted strangers a role it was
|
|
124
|
+
// never asked to grant is not recoverable by changing the setting back.
|
|
125
|
+
const GOVERNANCE_DEFAULT = Object.freeze({
|
|
126
|
+
preset: 'owner-only',
|
|
127
|
+
reason: 'Nobody has said who is building this yet, so this is the narrowest setup — widen it whenever you like.',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Team shape -> who is worth suggesting, as a SEEK — the kind of builder to look
|
|
131
|
+
// for and how many. This engine is pure and cannot run the lookup (the
|
|
132
|
+
// leaderboard lives in the `economy` module, which this one may not import), so
|
|
133
|
+
// `seeking` is the seam R28 (task 1002347) reads and `suggested` stays empty.
|
|
134
|
+
// `seeking: null` means "suggest nobody", which is a real answer and not a
|
|
135
|
+
// missing one.
|
|
136
|
+
const INVITE_RULES = Object.freeze([
|
|
137
|
+
Object.freeze({
|
|
138
|
+
teamShape: 'solo',
|
|
139
|
+
seeking: null,
|
|
140
|
+
reason: 'You said you are building alone, so nobody is suggested. You can invite anyone later from the project settings.',
|
|
141
|
+
}),
|
|
142
|
+
Object.freeze({
|
|
143
|
+
teamShape: 'small-team',
|
|
144
|
+
seeking: Object.freeze({ kind: 'top-builders', limit: 3 }),
|
|
145
|
+
reason: 'A small team is the shape that fills fastest, so here is a short list of the builders with the strongest recent record.',
|
|
146
|
+
}),
|
|
147
|
+
Object.freeze({
|
|
148
|
+
teamShape: 'community',
|
|
149
|
+
seeking: Object.freeze({ kind: 'top-builders', limit: 5 }),
|
|
150
|
+
reason: 'An open community wants a wider first circle, so the list runs longer. Guilds join it once guilds can be invited.',
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
// What a skipped interview gets: nobody. Same reasoning as the governance
|
|
155
|
+
// default — an invite suggested on an answer nobody gave is a stranger the owner
|
|
156
|
+
// never asked for.
|
|
157
|
+
const INVITE_DEFAULT = Object.freeze({
|
|
158
|
+
seeking: null,
|
|
159
|
+
reason: 'Nobody has said who is building this yet, so nothing is suggested. Invites can be sent any time from the project settings.',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The interview's answer, or null. Anything outside `PROJECT_DETAIL_VOCAB` —
|
|
163
|
+
// absent, misspelled, or a value that was valid when it was stored and has since
|
|
164
|
+
// left the vocabulary — reads as NO ANSWER, exactly as `normalizeProjectDetail`
|
|
165
|
+
// drops it on the way in. PURE.
|
|
166
|
+
function normalizeTeamShape(value) {
|
|
167
|
+
return PROJECT_DETAIL_VOCAB.team_shape.includes(value) ? value : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
|
|
171
|
+
// list plus the adjustments that ACTUALLY changed it — a rule whose module the
|
|
172
|
+
// bundle already carries (or already lacks) records nothing, because a stated
|
|
173
|
+
// reason for a change that did not happen is a lie the owner cannot check.
|
|
174
|
+
// Never touches the always-on core. PURE.
|
|
175
|
+
function adjustBundle(bundle, teamShape) {
|
|
176
|
+
const modules = [...bundle];
|
|
177
|
+
const adjustments = [];
|
|
178
|
+
for (const rule of TEAM_SHAPE_RULES) {
|
|
179
|
+
if (rule.teamShape !== teamShape) continue;
|
|
180
|
+
const at = modules.indexOf(rule.module);
|
|
181
|
+
if (rule.effect === 'add' && at === -1) modules.push(rule.module);
|
|
182
|
+
else if (rule.effect === 'remove' && at !== -1) modules.splice(at, 1);
|
|
183
|
+
else continue;
|
|
184
|
+
adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
|
|
185
|
+
}
|
|
186
|
+
return { modules, adjustments };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The whole recommendation for one set of creation answers:
|
|
190
|
+
//
|
|
191
|
+
// {
|
|
192
|
+
// type, teamShape, // what was actually declared/answered (null = not)
|
|
193
|
+
// tailored, // did a DETAIL answer shape any of this?
|
|
194
|
+
// bundle: { type, label, summary, core, bundle, modules, adjustments },
|
|
195
|
+
// governance: { preset, label, preview, reason, applied },
|
|
196
|
+
// invites: { suggested, seeking, reason },
|
|
197
|
+
// }
|
|
198
|
+
//
|
|
199
|
+
// `bundle` is ADR 0237 §5's shape with one key added, so R20's picker and this
|
|
200
|
+
// engine can never name different modules; `adjustments` is the delta the rules
|
|
201
|
+
// made to it and is empty when nothing fired. `tailored` is false when the
|
|
202
|
+
// interview was skipped — the machine-readable half of criterion
|
|
203
|
+
// `proj-detail-interview`'s "completing it produces tailored recommendations;
|
|
204
|
+
// skipping produces sensible defaults", and what R18 (task 1002337) proves.
|
|
205
|
+
//
|
|
206
|
+
// Accepts a flat object because the two answers live in two places: `type` is a
|
|
207
|
+
// column and `team_shape` is a key of the `detail` jsonb, so a caller holding a
|
|
208
|
+
// row passes `{ type: row.type, ...projectDetail(row) }`. Never throws and never
|
|
209
|
+
// invents an answer. PURE — result arrays and objects are fresh, so a caller may
|
|
210
|
+
// sort or splice them without corrupting the frozen tables.
|
|
211
|
+
function recommendationsForDetails(answers) {
|
|
212
|
+
const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
|
|
213
|
+
const teamShape = normalizeTeamShape(given.team_shape);
|
|
214
|
+
const preset = bundleForProjectType(given.type);
|
|
215
|
+
const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
|
|
216
|
+
|
|
217
|
+
const gov = GOVERNANCE_RULES.find((r) => r.teamShape === teamShape) || GOVERNANCE_DEFAULT;
|
|
218
|
+
const invite = INVITE_RULES.find((r) => r.teamShape === teamShape) || INVITE_DEFAULT;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
type: preset.type,
|
|
222
|
+
teamShape,
|
|
223
|
+
tailored: teamShape !== null,
|
|
224
|
+
bundle: {
|
|
225
|
+
type: preset.type,
|
|
226
|
+
label: preset.label,
|
|
227
|
+
summary: preset.summary,
|
|
228
|
+
core: [...ALWAYS_ON_CORE],
|
|
229
|
+
bundle: modules,
|
|
230
|
+
modules: [...ALWAYS_ON_CORE, ...modules],
|
|
231
|
+
adjustments,
|
|
232
|
+
},
|
|
233
|
+
governance: {
|
|
234
|
+
preset: gov.preset,
|
|
235
|
+
label: GOVERNANCE_PRESETS[gov.preset].label,
|
|
236
|
+
preview: GOVERNANCE_PRESETS[gov.preset].preview,
|
|
237
|
+
reason: gov.reason,
|
|
238
|
+
// Always false until the governance module ships (goal 1000030). A named
|
|
239
|
+
// recommendation, never an applied setting — criterion `proj-governance-reco`.
|
|
240
|
+
applied: false,
|
|
241
|
+
},
|
|
242
|
+
invites: {
|
|
243
|
+
// The seam, not an omission: ranking builders is a leaderboard query this
|
|
244
|
+
// pure file may not run. R28 (task 1002347) fills this from `seeking`.
|
|
245
|
+
suggested: [],
|
|
246
|
+
seeking: invite.seeking ? { ...invite.seeking } : null,
|
|
247
|
+
reason: invite.reason,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
TEAM_SHAPE_RULES,
|
|
254
|
+
GOVERNANCE_PRESETS,
|
|
255
|
+
GOVERNANCE_RULES,
|
|
256
|
+
GOVERNANCE_DEFAULT,
|
|
257
|
+
INVITE_RULES,
|
|
258
|
+
INVITE_DEFAULT,
|
|
259
|
+
recommendationsForDetails,
|
|
260
|
+
};
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// GET /provisioning/health — liveness + core version
|
|
24
24
|
// GET /provisioning/onboard-plan — the canonical onboarding step sequence
|
|
25
25
|
// GET /provisioning/starter-bundles — the curated module preset per project type
|
|
26
|
+
// GET /provisioning/recommendations — bundle/governance/invite recommendations from the answers
|
|
26
27
|
//
|
|
27
28
|
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
28
29
|
// 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
|
|
@@ -36,6 +37,7 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
36
37
|
const provisioning = require('../provisioning');
|
|
37
38
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
38
39
|
const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
|
|
40
|
+
const { recommendationsForDetails } = require('../recommendations');
|
|
39
41
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
40
42
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
41
43
|
const log = api.logger('provisioning');
|
|
@@ -1282,6 +1284,33 @@ module.exports = function provisioningRoutes() {
|
|
|
1282
1284
|
res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
|
|
1283
1285
|
});
|
|
1284
1286
|
|
|
1287
|
+
// GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>
|
|
1288
|
+
// — the RULE-BASED recommendation for one set of creation answers (BV1.R17, task
|
|
1289
|
+
// 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
|
|
1290
|
+
// `proj-invite-builders`). Returns `{ recommendations: { type, teamShape, tailored,
|
|
1291
|
+
// bundle, governance, invites } }` — the starter bundle adjusted by the interview
|
|
1292
|
+
// answer with a `reason` per adjustment, a named governance preset with its preview
|
|
1293
|
+
// (`applied: false` until the governance module ships, goal 1000030), and the invite
|
|
1294
|
+
// SEEK that R28 (task 1002347) turns into names.
|
|
1295
|
+
//
|
|
1296
|
+
// The two answers arrive separately because they are stored separately: `type` is a
|
|
1297
|
+
// column and `team_shape` a key of the `detail` jsonb, so a caller passes back
|
|
1298
|
+
// exactly what it holds. Both are optional and neither is validated by rejection —
|
|
1299
|
+
// an absent or off-vocabulary value reads as NO ANSWER and yields the stated
|
|
1300
|
+
// defaults, which is the supported path (ADR 0182 D4: skipping the interview is a
|
|
1301
|
+
// real answer), never a 400.
|
|
1302
|
+
//
|
|
1303
|
+
// A pure read over modules/provisioning/recommendations.js — no database, no
|
|
1304
|
+
// per-request state. rank: public — the same absent gate as `starter-bundles` and
|
|
1305
|
+
// `onboard-plan` above: the signed-out hub wizard (/projects?view=new) asks while
|
|
1306
|
+
// the owner is still answering, before any row exists to be own-scoped against, and
|
|
1307
|
+
// the answer is a declaration that names nothing private.
|
|
1308
|
+
router.get('/provisioning/recommendations', (req, res) => {
|
|
1309
|
+
const type = typeof req.query.type === 'string' ? req.query.type : null;
|
|
1310
|
+
const teamShape = typeof req.query.team_shape === 'string' ? req.query.team_shape : null;
|
|
1311
|
+
res.json({ recommendations: recommendationsForDetails({ type, team_shape: teamShape }) });
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1285
1314
|
// GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
|
|
1286
1315
|
// (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
|
|
1287
1316
|
// this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.514",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.514",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.514",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.514'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -236,6 +236,90 @@ test('the session log pages on the kit — the last hand-roll is gone', () => {
|
|
|
236
236
|
assert.match(read('sessions.html'), /hall-kit\.js/, 'and the page has to load the kit');
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
// ---- 6b. every hall bar series is drawn by the kit (task 1003445) ---------
|
|
240
|
+
|
|
241
|
+
test('the kit is the ONLY thing in the hall that draws a bar series', () => {
|
|
242
|
+
// Charts were the one primitive the kit never got, and the hard rule in
|
|
243
|
+
// hall-ui/CLAUDE.md names pager/filter/tab/rail and stops — so the same
|
|
244
|
+
// horizontal bar got written THREE times, each with its own class family:
|
|
245
|
+
// the Curve's kinds (hall-render.js), Watch's Review-quality trend
|
|
246
|
+
// (watch.js) and the profile's craft mix (profile-charts.js `mixChart`).
|
|
247
|
+
// Nothing failed when the third copy landed, which is the whole reason this
|
|
248
|
+
// check exists rather than a sentence in a doc.
|
|
249
|
+
//
|
|
250
|
+
// Two halves, because one alone is escapable.
|
|
251
|
+
//
|
|
252
|
+
// (a) OWNERSHIP, the pager rule's own idiom: the kit stamps `.kit-bars` /
|
|
253
|
+
// `.kit-spark` at runtime and callers pass DATA, so the class appearing
|
|
254
|
+
// in another file is by construction a second implementation.
|
|
255
|
+
const OWNER = 'hall-kit.js';
|
|
256
|
+
const stamped = [];
|
|
257
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
258
|
+
if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
|
|
259
|
+
if (/class="kit-(?:bars|spark|plot)[\s"_-]/.test(read(f))) stamped.push(f);
|
|
260
|
+
}
|
|
261
|
+
assert.deepEqual(stamped, [],
|
|
262
|
+
`${stamped.join(', ')} writes the kit's own chart classes by hand — call `
|
|
263
|
+
+ 'OTBKit.barSeriesHtml / sparklineHtml / plotSvgHtml instead.');
|
|
264
|
+
|
|
265
|
+
// No page may draw an SVG series of its own either — a <path>/<rect>/
|
|
266
|
+
// <polyline> built in a template string IS chart drawing, whatever it is
|
|
267
|
+
// classed. This is what the narc caught on the first round: the bars were
|
|
268
|
+
// unified while two hand-rolled SVG charts stayed behind.
|
|
269
|
+
const svgDrawers = [];
|
|
270
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
271
|
+
if (!/\.js$/.test(f) || f === OWNER) continue;
|
|
272
|
+
const src = read(f);
|
|
273
|
+
if (/<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/.test(src)) svgDrawers.push(f);
|
|
274
|
+
}
|
|
275
|
+
assert.deepEqual(svgDrawers, [],
|
|
276
|
+
`${svgDrawers.join(', ')} builds SVG series geometry by hand — the kit owns the `
|
|
277
|
+
+ 'shapes (timeStepSeriesHtml, columnSeriesHtml, sparklineHtml) and plotSvgHtml owns '
|
|
278
|
+
+ 'the axis frame; a page composes them and wires its own readout.');
|
|
279
|
+
|
|
280
|
+
// (b) SHAPE, so a SEVENTH copy under a fresh class family is caught too —
|
|
281
|
+
// which (a) alone would miss, and which is exactly how copies two and
|
|
282
|
+
// three arrived. A bar series is a percentage-width fill inside a
|
|
283
|
+
// track/bar/fill-named element.
|
|
284
|
+
//
|
|
285
|
+
// The allowlist is METERS, not series, and the distinction is real: a
|
|
286
|
+
// meter shows ONE value against a fixed 0-100 scale (and bands it), a
|
|
287
|
+
// series shows several values against each other's computed max. The kit
|
|
288
|
+
// draws the second; folding the first into it would mean inventing a
|
|
289
|
+
// scale the meter does not have. Adding a name here is where the
|
|
290
|
+
// conversation happens — do not add one to make a chart pass.
|
|
291
|
+
const METERS = new Set([
|
|
292
|
+
'idea.js', // .ig-score — one Full-Idea completeness gauge, banded full/part/thin
|
|
293
|
+
'ideas.js', // .fi-mrow — the composer's per-field completeness, driven imperatively
|
|
294
|
+
]);
|
|
295
|
+
const handRolled = [];
|
|
296
|
+
for (const f of fs.readdirSync(HALL)) {
|
|
297
|
+
if (!/\.js$/.test(f) || f === OWNER || METERS.has(f)) continue;
|
|
298
|
+
const src = read(f);
|
|
299
|
+
if (/class="[^"]*(?:__|-)(?:fill|bar|track)[^"]*"[^>]*style="width:/.test(src)) handRolled.push(f);
|
|
300
|
+
}
|
|
301
|
+
assert.deepEqual(handRolled, [],
|
|
302
|
+
`${handRolled.join(', ')} draws a bar series by hand — call OTBKit.barSeriesHtml(rows, opts). `
|
|
303
|
+
+ 'Geometry and tone are options (labelWidth/valueWidth/barHeight/radius/track/tone), so a new '
|
|
304
|
+
+ 'chart needs no new drawing code and no new class family.');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('the three bar copies are gone, and their class families with them', () => {
|
|
308
|
+
// The specific copies this rule replaced, pinned by name so their return is
|
|
309
|
+
// loud. Behaviour is exercised for real in tests/hall_kit_charts.mjs.
|
|
310
|
+
// Comments stripped: each sheet keeps a line SAYING which family it retired,
|
|
311
|
+
// and a substring check would read that sentence as the family still living.
|
|
312
|
+
const css = ['style.css', 'watch.css', 'profile.css']
|
|
313
|
+
.map((f) => read(f).replace(/\/\*[\s\S]*?\*\//g, '')).join('\n');
|
|
314
|
+
for (const dead of ['curve__kind-', 'curve__chart', 'watch-trend', 'pmix']) {
|
|
315
|
+
assert.ok(!css.includes(dead), `${dead} is the kit's .kit-bars/.kit-chart now`);
|
|
316
|
+
}
|
|
317
|
+
assert.match(read('hall-render.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
|
|
318
|
+
assert.match(read('watch.js'), /OTBKit\.barSeriesHtml\(/, 'Watch\'s trend draws through the kit');
|
|
319
|
+
assert.match(read('profile-charts.js'), /OTBKit\.barSeriesHtml\(/, 'the craft mix draws through the kit');
|
|
320
|
+
assert.match(read('hall-render.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
|
|
321
|
+
});
|
|
322
|
+
|
|
239
323
|
test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
|
|
240
324
|
assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
|
|
241
325
|
const js = read('builders.js');
|