@bongos/core 1.20.53 → 1.20.54
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 +39 -29
- 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 +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +92 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +21 -12
- package/docs/copy-registry.json +95 -14
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +52 -40
- package/modules/government/board.js +15 -21
- package/modules/government/recommendations.js +218 -0
- package/modules/government/routes/government.js +47 -1
- package/modules/hall-ui/public/government.js +125 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_library.mjs +11 -5
- package/tests/government_constitution_view.mjs +4 -1
- package/tests/government_recommendations.mjs +119 -0
- package/tests/government_routes.mjs +35 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/recommendations.js — what government and which ranks this
|
|
4
|
+
// project should start from, by PROJECT TYPE and SIZE (task 1004400, criterion
|
|
5
|
+
// wa7-government: "Recommended base ranks and governments ship for different
|
|
6
|
+
// project types and sizes").
|
|
7
|
+
//
|
|
8
|
+
// TWO INPUTS. `type` is one of the project types the create flow declares
|
|
9
|
+
// (game / research / business / non-profit / not-sure — the vocabulary of
|
|
10
|
+
// platform_identity_006 and provisioning_007; mirrored here because a module
|
|
11
|
+
// may not import a sibling, ADR 0083, and held equal by
|
|
12
|
+
// tests/government_recommendations.mjs). `headcount` is the live count of active
|
|
13
|
+
// builders (db.countActiveBuilders) or a size the reader asks about. An unknown
|
|
14
|
+
// type reads as `not-sure`, and an unreadable headcount reads as the founding
|
|
15
|
+
// size. Neither ever throws.
|
|
16
|
+
//
|
|
17
|
+
// NEVER AN UNADOPTABLE CHARTER. The ladder this started from (the Six Variables
|
|
18
|
+
// synthesis) names Presidential at ~9 and Federation past ~150, and the old
|
|
19
|
+
// recommendation returned both, though neither can be adopted: they need
|
|
20
|
+
// elections and federated chambers this system does not have. So the ladder now
|
|
21
|
+
// resolves every size to an AVAILABLE form in charter.js FORMS, and when the
|
|
22
|
+
// ladder's own shape is one that is coming later, the answer says so in
|
|
23
|
+
// `later` rather than recommending it. A test walks every type × size.
|
|
24
|
+
//
|
|
25
|
+
// A FOUNDATION ALWAYS COMES WITH ITS COUNCIL. Foundation is governed by a
|
|
26
|
+
// custom rank the project names. Wherever this file recommends it, the
|
|
27
|
+
// recommended rank set carries that rank and the charter names it in
|
|
28
|
+
// `council_rank`, so the recommendation can be followed end to end: add the
|
|
29
|
+
// ranks, assign the council, propose the charter.
|
|
30
|
+
//
|
|
31
|
+
// RANK SETS ADD TO THE LADDER, NEVER REPLACE IT. The four seeded ranks are
|
|
32
|
+
// every project's base (xenos → archon) and their grants come from the
|
|
33
|
+
// catalog. A recommended set is the CUSTOM ranks worth adding at a type and
|
|
34
|
+
// size, each with a short grant list. Every grant must be one a custom rank can
|
|
35
|
+
// actually hold (catalog.isGrantableTo) — checked by test, and filtered again
|
|
36
|
+
// where a set is applied, so a set can never write what the toggle refuses.
|
|
37
|
+
//
|
|
38
|
+
// Pure and DB-free.
|
|
39
|
+
|
|
40
|
+
const charter = require('./charter');
|
|
41
|
+
|
|
42
|
+
const freeze = (o) => Object.freeze(o);
|
|
43
|
+
|
|
44
|
+
// ── project types ────────────────────────────────────────────────────────────
|
|
45
|
+
const PROJECT_TYPES = freeze([
|
|
46
|
+
freeze({ key: 'game', name: 'Game' }),
|
|
47
|
+
freeze({ key: 'research', name: 'Research' }),
|
|
48
|
+
freeze({ key: 'business', name: 'Business' }),
|
|
49
|
+
freeze({ key: 'non-profit', name: 'Non-profit' }),
|
|
50
|
+
freeze({ key: 'not-sure', name: 'Not sure yet' }),
|
|
51
|
+
]);
|
|
52
|
+
const TYPE_KEYS = PROJECT_TYPES.map((t) => t.key);
|
|
53
|
+
|
|
54
|
+
function normalizeType(type) {
|
|
55
|
+
return TYPE_KEYS.includes(type) ? type : 'not-sure';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── sizes ────────────────────────────────────────────────────────────────────
|
|
59
|
+
// The ladder's own thresholds. `example` is the headcount a reader asking
|
|
60
|
+
// "what about a project this size" is shown.
|
|
61
|
+
const SIZE_BANDS = freeze([
|
|
62
|
+
freeze({ key: 'solo', name: 'Just you, or two', min: 0, max: 2, example: 1 }),
|
|
63
|
+
freeze({ key: 'circle', name: '3 to 8 builders', min: 3, max: 8, example: 5 }),
|
|
64
|
+
freeze({ key: 'team', name: '9 to 49 builders', min: 9, max: 49, example: 20 }),
|
|
65
|
+
freeze({ key: 'organisation', name: '50 to 149 builders', min: 50, max: 149, example: 80 }),
|
|
66
|
+
freeze({ key: 'large', name: '150 builders or more', min: 150, max: Infinity, example: 200 }),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function sizeBandFor(headcount) {
|
|
70
|
+
const n = Math.max(0, Math.floor(Number(headcount) || 0));
|
|
71
|
+
return SIZE_BANDS.find((b) => n >= b.min && n <= b.max);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── the jobs a rank set is made of ──────────────────────────────────────────
|
|
75
|
+
// Three jobs a growing project hands out beyond the ladder. The grants are the
|
|
76
|
+
// operational keys that job needs — never a trust-boundary key (a custom rank
|
|
77
|
+
// cannot hold one, ADR 0172).
|
|
78
|
+
const JOB_GRANTS = freeze({
|
|
79
|
+
reviewer: freeze(['criterion.review', 'task.vote', 'idea.triage']),
|
|
80
|
+
lead: freeze(['task.create', 'task.edit', 'task.promote', 'task.dependency.manage',
|
|
81
|
+
'goal.manage.own', 'idea.triage', 'criterion.review']),
|
|
82
|
+
council: freeze(['board.item.open', 'idea.ratify', 'criterion.satisfy', 'criterion.unsatisfy',
|
|
83
|
+
'goal.create', 'priority.run']),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// What each type calls those jobs. The rank key is what gets created, so it is
|
|
87
|
+
// a kebab-case slug the rank API accepts.
|
|
88
|
+
const JOB_NAMES = freeze({
|
|
89
|
+
game: freeze({
|
|
90
|
+
reviewer: freeze({ key: 'playtester', label: 'Playtester', description: 'Plays the builds, checks work against what "done" means, and sorts incoming ideas.' }),
|
|
91
|
+
lead: freeze({ key: 'design-lead', label: 'Design lead', description: 'Shapes the work: writes and orders tasks and looks after the goals they own.' }),
|
|
92
|
+
council: freeze({ key: 'studio-council', label: 'Studio council', description: 'Sits on the board: decides what the game becomes and when a goal is met.' }),
|
|
93
|
+
}),
|
|
94
|
+
research: freeze({
|
|
95
|
+
reviewer: freeze({ key: 'peer-reviewer', label: 'Peer reviewer', description: 'Reviews results against the criteria and sorts incoming ideas.' }),
|
|
96
|
+
lead: freeze({ key: 'principal-investigator', label: 'Principal investigator', description: 'Shapes a line of work: writes and orders its tasks and looks after its goals.' }),
|
|
97
|
+
council: freeze({ key: 'steering-committee', label: 'Steering committee', description: 'Sits on the board: sets the research direction and decides when a goal is met.' }),
|
|
98
|
+
}),
|
|
99
|
+
business: freeze({
|
|
100
|
+
reviewer: freeze({ key: 'reviewer', label: 'Reviewer', description: 'Checks work against what "done" means and sorts incoming ideas.' }),
|
|
101
|
+
lead: freeze({ key: 'product-lead', label: 'Product lead', description: 'Shapes the product: writes and orders tasks and looks after the goals they own.' }),
|
|
102
|
+
council: freeze({ key: 'leadership-council', label: 'Leadership council', description: 'Sits on the board: sets direction and decides when a goal is met.' }),
|
|
103
|
+
}),
|
|
104
|
+
'non-profit': freeze({
|
|
105
|
+
reviewer: freeze({ key: 'reviewer', label: 'Reviewer', description: 'Checks work against what "done" means and sorts incoming ideas.' }),
|
|
106
|
+
lead: freeze({ key: 'programme-lead', label: 'Programme lead', description: 'Runs a programme: writes and orders its tasks and looks after its goals.' }),
|
|
107
|
+
council: freeze({ key: 'trustees', label: 'Trustees', description: 'Sits on the board: holds the mission and decides when a goal is met.' }),
|
|
108
|
+
}),
|
|
109
|
+
'not-sure': freeze({
|
|
110
|
+
reviewer: freeze({ key: 'reviewer', label: 'Reviewer', description: 'Checks work against what "done" means and sorts incoming ideas.' }),
|
|
111
|
+
lead: freeze({ key: 'lead', label: 'Lead', description: 'Shapes the work: writes and orders tasks and looks after the goals they own.' }),
|
|
112
|
+
council: freeze({ key: 'council', label: 'Council', description: 'Sits on the board: sets direction and decides when a goal is met.' }),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// "a game project", or plain "a project" when the type is not known.
|
|
117
|
+
const projectPhrase = (type) => (type === 'not-sure' ? 'a project' : `a ${PROJECT_TYPES.find((t) => t.key === type).name.toLowerCase()} project`);
|
|
118
|
+
|
|
119
|
+
// ── the charter ladder, resolved to forms that can be adopted ───────────────
|
|
120
|
+
// One row per size. `form` must be AVAILABLE in charter.js FORMS. `later` names
|
|
121
|
+
// the ladder's own shape at that size when it is one still to come. A type may
|
|
122
|
+
// override a size (a non-profit is governed by trustees early).
|
|
123
|
+
const CHARTER_LADDER = freeze({
|
|
124
|
+
solo: freeze({ form: 'monarchy', rung: 'solo/pair',
|
|
125
|
+
why: 'informal coordination has nothing to break yet — the founding monarchy is the honest shape' }),
|
|
126
|
+
circle: freeze({ form: 'direct-democracy', rung: 'circle (3–8)',
|
|
127
|
+
why: 'a consent circle of every builder (rank:xenos+) — small enough that everyone can decide together' }),
|
|
128
|
+
team: freeze({ form: 'timocratic-republic', rung: '~9: the circle gets too big', later: 'presidential',
|
|
129
|
+
why: 'past ~9, everyone deciding everything stops working — the builders who have earned the trusted rank (rank:metic+) decide by consent' }),
|
|
130
|
+
organisation: freeze({ form: 'foundation', rung: '~50: written charter + council',
|
|
131
|
+
why: 'past ~50, the charter must be written down and a council you appoint carries it' }),
|
|
132
|
+
large: freeze({ form: 'foundation', rung: '~150: a council carries it', later: 'federation',
|
|
133
|
+
why: 'past ~150 the ladder federates into self-governing teams; until that exists, a council you appoint carries the charter' }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const TYPE_OVERRIDES = freeze({
|
|
137
|
+
'non-profit': freeze({
|
|
138
|
+
team: freeze({ form: 'foundation', rung: '~9: trustees hold the mission',
|
|
139
|
+
why: 'a non-profit answers to its mission, so trustees you appoint carry it as soon as the circle is too big to decide together' }),
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const formByKey = (key) => charter.FORMS.find((f) => f.key === key) || null;
|
|
144
|
+
|
|
145
|
+
function recommendedCharter(headcount, type) {
|
|
146
|
+
const t = normalizeType(type);
|
|
147
|
+
const size = sizeBandFor(headcount);
|
|
148
|
+
const row = (TYPE_OVERRIDES[t] && TYPE_OVERRIDES[t][size.key]) || CHARTER_LADDER[size.key];
|
|
149
|
+
const form = formByKey(row.form);
|
|
150
|
+
const later = row.later ? formByKey(row.later) : null;
|
|
151
|
+
const councilRank = row.form === 'foundation' ? JOB_NAMES[t].council.key : null;
|
|
152
|
+
return freeze({
|
|
153
|
+
key: row.form,
|
|
154
|
+
name: form.name,
|
|
155
|
+
rung: row.rung,
|
|
156
|
+
why: row.why,
|
|
157
|
+
type: t,
|
|
158
|
+
size: size.key,
|
|
159
|
+
council_rank: councilRank,
|
|
160
|
+
later: later ? freeze({ key: later.key, name: later.name, explanation: later.explanation }) : null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── the rank set ─────────────────────────────────────────────────────────────
|
|
165
|
+
// Which jobs a size hands out. Any size whose charter is a Foundation also gets
|
|
166
|
+
// the council (that is the non-profit's team size), so the two recommendations
|
|
167
|
+
// never disagree.
|
|
168
|
+
const SIZE_JOBS = freeze({
|
|
169
|
+
solo: freeze([]),
|
|
170
|
+
circle: freeze(['reviewer']),
|
|
171
|
+
team: freeze(['reviewer', 'lead']),
|
|
172
|
+
organisation: freeze(['reviewer', 'lead', 'council']),
|
|
173
|
+
large: freeze(['reviewer', 'lead', 'council']),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
function recommendedRankSet(type, headcount) {
|
|
177
|
+
const t = normalizeType(type);
|
|
178
|
+
const size = sizeBandFor(headcount);
|
|
179
|
+
const jobs = [...SIZE_JOBS[size.key]];
|
|
180
|
+
if (recommendedCharter(headcount, t).council_rank && !jobs.includes('council')) jobs.push('council');
|
|
181
|
+
const ranks = jobs.map((job) => freeze({
|
|
182
|
+
job,
|
|
183
|
+
rank_key: JOB_NAMES[t][job].key,
|
|
184
|
+
label: JOB_NAMES[t][job].label,
|
|
185
|
+
description: JOB_NAMES[t][job].description,
|
|
186
|
+
permissions: JOB_GRANTS[job],
|
|
187
|
+
}));
|
|
188
|
+
const why = ranks.length
|
|
189
|
+
? `At this size ${projectPhrase(t)} hands out ${ranks.length === 1 ? 'one job' : `${ranks.length} jobs`} beyond the four standard ranks. Each is added as a custom rank you can edit or delete.`
|
|
190
|
+
: 'At this size the four standard ranks are enough — there is nobody yet to hand a separate job to.';
|
|
191
|
+
return freeze({ type: t, size: size.key, why, ranks: freeze(ranks) });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Both recommendations for one project, plus the vocabulary a page needs to
|
|
195
|
+
// let a reader ask about another type or size.
|
|
196
|
+
function recommendationsFor({ type, headcount } = {}) {
|
|
197
|
+
return freeze({
|
|
198
|
+
type: normalizeType(type),
|
|
199
|
+
headcount: Math.max(0, Math.floor(Number(headcount) || 0)),
|
|
200
|
+
size: sizeBandFor(headcount).key,
|
|
201
|
+
charter: recommendedCharter(headcount, type),
|
|
202
|
+
rank_set: recommendedRankSet(type, headcount),
|
|
203
|
+
project_types: PROJECT_TYPES,
|
|
204
|
+
size_bands: SIZE_BANDS.map((b) => freeze({ key: b.key, name: b.name, example: b.example })),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
PROJECT_TYPES,
|
|
210
|
+
SIZE_BANDS,
|
|
211
|
+
JOB_GRANTS,
|
|
212
|
+
JOB_NAMES,
|
|
213
|
+
normalizeType,
|
|
214
|
+
sizeBandFor,
|
|
215
|
+
recommendedCharter,
|
|
216
|
+
recommendedRankSet,
|
|
217
|
+
recommendationsFor,
|
|
218
|
+
};
|
|
@@ -25,6 +25,7 @@ const db = require('../db');
|
|
|
25
25
|
const sessionScopes = require('../session-scopes');
|
|
26
26
|
const boardMembership = require('../board-membership');
|
|
27
27
|
const board = require('../board');
|
|
28
|
+
const recommendations = require('../recommendations');
|
|
28
29
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
29
30
|
const log = api.logger('government');
|
|
30
31
|
|
|
@@ -302,6 +303,45 @@ module.exports = function buildGovernmentRouter() {
|
|
|
302
303
|
}
|
|
303
304
|
});
|
|
304
305
|
|
|
306
|
+
// rank: perm government.manage — add a RECOMMENDED RANK SET (task 1004400):
|
|
307
|
+
// the custom ranks recommendations.js names for a project type and size, each
|
|
308
|
+
// with its grants. It is the create form and the grant toggle run in a loop,
|
|
309
|
+
// under the same checks: a rank that already exists is LEFT ALONE (never
|
|
310
|
+
// re-granted, never relabelled — an owner may have edited it), and every grant
|
|
311
|
+
// is re-checked with catalog.isGrantableTo, so a set can never write what the
|
|
312
|
+
// toggle would refuse. Re-applying is safe; the reply says what was added.
|
|
313
|
+
router.post('/government/rank-sets/apply', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
314
|
+
if (validateOrRespond(req, res, {
|
|
315
|
+
type: { type: 'string', maxLength: 32 },
|
|
316
|
+
headcount: { required: true, type: 'integer', min: 0, max: 100000 },
|
|
317
|
+
})) return;
|
|
318
|
+
const set = recommendations.recommendedRankSet(req.body.type, req.body.headcount);
|
|
319
|
+
const created = [];
|
|
320
|
+
const skipped = [];
|
|
321
|
+
try {
|
|
322
|
+
for (const r of set.ranks) {
|
|
323
|
+
if (await db.getRankByKey(r.rank_key)) { skipped.push(r.rank_key); continue; }
|
|
324
|
+
let rank;
|
|
325
|
+
try {
|
|
326
|
+
rank = await db.createRank({ rankKey: r.rank_key, label: r.label, description: r.description, createdBy: req.builder.id });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err && err.code === '23505') { skipped.push(r.rank_key); continue; }
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
const granted = [];
|
|
332
|
+
for (const key of r.permissions) {
|
|
333
|
+
if (!catalog.byKey(key) || !catalog.isGrantableTo(key, rank)) continue;
|
|
334
|
+
if (await db.grantRankPermission({ rankKey: r.rank_key, permissionKey: key })) granted.push(key);
|
|
335
|
+
}
|
|
336
|
+
created.push({ rank_key: r.rank_key, label: r.label, permissions: granted });
|
|
337
|
+
}
|
|
338
|
+
res.json({ ok: true, type: set.type, size: set.size, created, skipped });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
log.error('[gds] POST /government/rank-sets/apply', err);
|
|
341
|
+
res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created, skipped } });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
305
345
|
// rank: perm government.manage — grant one permission to a rank (the toggle's ON
|
|
306
346
|
// half). The order of refusals is the authority order: rank exists → rank is not
|
|
307
347
|
// the Archon one → the key exists → the key is grantable TO THIS RANK.
|
|
@@ -465,7 +505,13 @@ module.exports = function buildGovernmentRouter() {
|
|
|
465
505
|
// immediately (the same instant-effect posture as everything else here).
|
|
466
506
|
router.get('/government/constitution', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
467
507
|
try {
|
|
468
|
-
|
|
508
|
+
// ?type= and ?headcount= ask about another project type or size (task
|
|
509
|
+
// 1004400). Both are advice-only inputs: an unknown type reads as "not
|
|
510
|
+
// sure" and the size is bounded, so neither can change what is in force.
|
|
511
|
+
const type = typeof req.query.type === 'string' ? req.query.type : null;
|
|
512
|
+
const asked = Number.parseInt(req.query.headcount, 10);
|
|
513
|
+
const headcount = Number.isFinite(asked) && asked >= 0 ? Math.min(asked, 100000) : null;
|
|
514
|
+
res.json(await board.constitutionView({ historyLimit: 50, type, headcount }));
|
|
469
515
|
} catch (err) {
|
|
470
516
|
log.error('[gds] GET /government/constitution', err);
|
|
471
517
|
res.fail('constitution_read_failed', { status: 500, message: 'internal error' });
|
|
@@ -790,7 +790,7 @@
|
|
|
790
790
|
function charterCard(c, recommendedKey) {
|
|
791
791
|
const facts = [
|
|
792
792
|
c.selectable ? pill('ok', 'selectable today') : pill('quiet', 'documented for later'),
|
|
793
|
-
c.key === recommendedKey ? pill('info', 'recommended
|
|
793
|
+
c.key === recommendedKey ? pill('info', 'recommended for this type and size') : '',
|
|
794
794
|
].join('');
|
|
795
795
|
const how = c.how ? `<p class="gov-charter__how">as configuration: ${escapeHtml(c.how)}</p>` : '';
|
|
796
796
|
return `<li class="ov-row"><div class="ov-row__main">
|
|
@@ -820,12 +820,131 @@
|
|
|
820
820
|
`;
|
|
821
821
|
}
|
|
822
822
|
|
|
823
|
+
// ---- the recommendation (task 1004400) --------------------------------------
|
|
824
|
+
|
|
825
|
+
// What government and which ranks to start from, for a project TYPE and SIZE.
|
|
826
|
+
// Both answers come from the server (modules/government/recommendations.js),
|
|
827
|
+
// which only ever names a form that can be adopted. A running project does
|
|
828
|
+
// not record the type it declared at creation, so the reader picks it here,
|
|
829
|
+
// and the choice is remembered in this browser only — it is advice, not a
|
|
830
|
+
// setting, and changes nothing that is in force.
|
|
831
|
+
const RECO_TYPE_KEY = 'gov.reco.type';
|
|
832
|
+
let canManageRanks = false;
|
|
833
|
+
let recoAsk = { type: null, headcount: null }; // null headcount = the live team
|
|
834
|
+
|
|
835
|
+
function readSavedType() {
|
|
836
|
+
try { return window.localStorage.getItem(RECO_TYPE_KEY); } catch (_) { return null; }
|
|
837
|
+
}
|
|
838
|
+
function saveType(type) {
|
|
839
|
+
try { window.localStorage.setItem(RECO_TYPE_KEY, type); } catch (_) { /* per-viewer convenience only */ }
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function constitutionPath() {
|
|
843
|
+
const q = new URLSearchParams();
|
|
844
|
+
if (recoAsk.type) q.set('type', recoAsk.type);
|
|
845
|
+
if (recoAsk.headcount !== null) q.set('headcount', String(recoAsk.headcount));
|
|
846
|
+
const qs = q.toString();
|
|
847
|
+
return `/government/constitution${qs ? `?${qs}` : ''}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function recoSelectsHtml(reco, liveHeadcount) {
|
|
851
|
+
const types = (reco.project_types || []).map((t) =>
|
|
852
|
+
`<option value="${escapeHtml(t.key)}"${t.key === reco.type ? ' selected' : ''}>${escapeHtml(t.name)}</option>`).join('');
|
|
853
|
+
const live = `<option value=""${recoAsk.headcount === null ? ' selected' : ''}>Your team now (${escapeHtml(String(liveHeadcount ?? '?'))} builder${liveHeadcount === 1 ? '' : 's'})</option>`;
|
|
854
|
+
const sizes = (reco.size_bands || []).map((b) =>
|
|
855
|
+
`<option value="${escapeHtml(String(b.example))}"${recoAsk.headcount === b.example ? ' selected' : ''}>${escapeHtml(b.name)}</option>`).join('');
|
|
856
|
+
return `
|
|
857
|
+
<form class="ov-form gov-reco__ask" id="gov-reco-form" autocomplete="off">
|
|
858
|
+
<div class="ov-field">
|
|
859
|
+
<label class="ov-field__l" for="gov-reco-type">Project type</label>
|
|
860
|
+
<select id="gov-reco-type">${types}</select>
|
|
861
|
+
</div>
|
|
862
|
+
<div class="ov-field">
|
|
863
|
+
<label class="ov-field__l" for="gov-reco-size">Size</label>
|
|
864
|
+
<select id="gov-reco-size">${live}${sizes}</select>
|
|
865
|
+
</div>
|
|
866
|
+
</form>`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function recoRankRow(r) {
|
|
870
|
+
return `<li class="ov-row"><div class="ov-row__main">
|
|
871
|
+
<span class="ov-row__title">${escapeHtml(r.label)}</span>
|
|
872
|
+
<p class="ov-row__sub">${escapeHtml(r.description)}</p>
|
|
873
|
+
<p class="gov-charter__how">rank ${escapeHtml(r.rank_key)} · may: ${escapeHtml(r.permissions.join(', '))}</p>
|
|
874
|
+
</div></li>`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function recommendationHtml(data) {
|
|
878
|
+
const reco = data.recommendation;
|
|
879
|
+
if (!reco) return '';
|
|
880
|
+
const c = reco.charter;
|
|
881
|
+
const set = reco.rank_set;
|
|
882
|
+
const council = c.council_rank
|
|
883
|
+
? `<p class="gov-charter__how">as configuration: membership rank:${escapeHtml(c.council_rank)} — the council rank below, once you have added it and assigned its members</p>`
|
|
884
|
+
: '';
|
|
885
|
+
const later = c.later
|
|
886
|
+
? `<p class="ov-row__sub">The ladder's own shape at this size is ${escapeHtml(c.later.name)}. ${escapeHtml(c.later.explanation)}</p>`
|
|
887
|
+
: '';
|
|
888
|
+
const apply = set.ranks.length && canManageRanks
|
|
889
|
+
? `<div class="gov-grid-actions"><button class="btn-accent" type="button" id="gov-reco-apply">Add these ranks</button></div>`
|
|
890
|
+
: '';
|
|
891
|
+
return `
|
|
892
|
+
<section class="scroll" aria-labelledby="gov-reco-h">
|
|
893
|
+
<h2 class="scroll__h" id="gov-reco-h">Recommended for your project</h2>
|
|
894
|
+
<p class="scroll__lede">A starting point by project type and size: a government you can adopt today, and the ranks worth adding to the four standard ones.</p>
|
|
895
|
+
${recoSelectsHtml(reco, data.headcount)}
|
|
896
|
+
<section class="ov-group">
|
|
897
|
+
${groupHead('Government', 1, c.rung)}
|
|
898
|
+
<ul class="ov-rows"><li class="ov-row"><div class="ov-row__main">
|
|
899
|
+
<span class="ov-row__title">${escapeHtml(c.name)}</span>
|
|
900
|
+
<p class="ov-row__sub">${escapeHtml(c.why)}</p>
|
|
901
|
+
${later}
|
|
902
|
+
<div class="ov-row__facts">${pill('ok', 'selectable today')}${pill('info', 'recommended')}</div>
|
|
903
|
+
${council}
|
|
904
|
+
</div></li></ul>
|
|
905
|
+
</section>
|
|
906
|
+
<section class="ov-group">
|
|
907
|
+
${groupHead('Ranks to add', set.ranks.length, 'on top of the four standard ranks')}
|
|
908
|
+
<p class="ov-row__sub">${escapeHtml(set.why)}</p>
|
|
909
|
+
${set.ranks.length ? `<ul class="ov-rows">${set.ranks.map(recoRankRow).join('')}</ul>` : ''}
|
|
910
|
+
${apply}
|
|
911
|
+
</section>
|
|
912
|
+
</section>`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function wireRecommendation(data) {
|
|
916
|
+
const type = document.getElementById('gov-reco-type');
|
|
917
|
+
const size = document.getElementById('gov-reco-size');
|
|
918
|
+
if (type) type.addEventListener('change', () => { recoAsk.type = type.value; saveType(type.value); loadConstitution(); });
|
|
919
|
+
if (size) size.addEventListener('change', () => { recoAsk.headcount = size.value === '' ? null : Number(size.value); loadConstitution(); });
|
|
920
|
+
const btn = document.getElementById('gov-reco-apply');
|
|
921
|
+
if (btn) btn.addEventListener('click', () => applyRankSet(data.recommendation, btn));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function applyRankSet(reco, btn) {
|
|
925
|
+
btn.disabled = true;
|
|
926
|
+
try {
|
|
927
|
+
const r = await sendJson('POST', '/government/rank-sets/apply', { type: reco.type, headcount: reco.headcount });
|
|
928
|
+
const made = (r.created || []).map((c) => c.label);
|
|
929
|
+
const kept = r.skipped || [];
|
|
930
|
+
toast(made.length
|
|
931
|
+
? `Added ${made.join(', ')}.${kept.length ? ` Already here, left as they were: ${kept.join(', ')}.` : ''}`
|
|
932
|
+
: 'Those ranks are already here — nothing changed.');
|
|
933
|
+
await reloadRanks();
|
|
934
|
+
} catch (err) {
|
|
935
|
+
if (err && err.kind === '401') return;
|
|
936
|
+
toast(`Could not add the ranks: ${err.message || err}`);
|
|
937
|
+
} finally {
|
|
938
|
+
btn.disabled = false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
823
942
|
async function loadConstitution() {
|
|
824
943
|
const live = document.getElementById('gov-constitution-live');
|
|
825
944
|
const box = document.getElementById('gov-constitution');
|
|
826
945
|
let data;
|
|
827
946
|
try {
|
|
828
|
-
data = await fetchJson(
|
|
947
|
+
data = await fetchJson(constitutionPath());
|
|
829
948
|
} catch (err) {
|
|
830
949
|
if (err && err.kind === '401') return;
|
|
831
950
|
live.innerHTML = emptyStateHtml('Could not load the constitution just now.');
|
|
@@ -855,8 +974,10 @@
|
|
|
855
974
|
? `<ul class="ov-rows">${history.map(historyRow).join('')}</ul>`
|
|
856
975
|
: emptyStateHtml('Never amended. This project still runs its founding constitution.')}
|
|
857
976
|
</section>
|
|
977
|
+
${recommendationHtml(data)}
|
|
858
978
|
${charterLibraryHtml(data)}
|
|
859
979
|
`;
|
|
980
|
+
wireRecommendation(data);
|
|
860
981
|
}
|
|
861
982
|
|
|
862
983
|
// ---- the Board Room: MOVED OUT (ADR 0266) ---------------------------------
|
|
@@ -931,6 +1052,8 @@
|
|
|
931
1052
|
document.getElementById('gov-sub').textContent = 'How this project is governed.';
|
|
932
1053
|
document.getElementById('gov-body').hidden = false;
|
|
933
1054
|
wireTabs({ isArchon });
|
|
1055
|
+
recoAsk.type = readSavedType();
|
|
1056
|
+
canManageRanks = isArchon;
|
|
934
1057
|
loadConstitution(); // the rule this project runs under (R18)
|
|
935
1058
|
|
|
936
1059
|
if (!isArchon) { sealRanksPanel(); return; }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.54",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.54",
|
|
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.20.
|
|
3
|
+
"version": "1.20.54",
|
|
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/release-notes.json
CHANGED
|
@@ -8435,5 +8435,11 @@
|
|
|
8435
8435
|
"id": "1004432",
|
|
8436
8436
|
"text": "An approved page now counts as the artist's own work everywhere the project shows who did what: their profile, their shipped count, their activity, the status page and the leaderboard. Before, the page was credited to the sess"
|
|
8437
8437
|
}
|
|
8438
|
+
],
|
|
8439
|
+
"1.20.54": [
|
|
8440
|
+
{
|
|
8441
|
+
"id": "1004400",
|
|
8442
|
+
"text": "The Government page now recommends a starting government and a starting set of ranks for your kind of project (game, research, business, non-profit) and its size. It only ever recommends a form you can adopt today. A team of a"
|
|
8443
|
+
}
|
|
8438
8444
|
]
|
|
8439
8445
|
}
|
|
@@ -253,6 +253,7 @@ const GOVERNMENT_PINS = {
|
|
|
253
253
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
254
254
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
255
255
|
'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
|
|
256
|
+
'POST /government/rank-sets/apply': 'perm:government.manage',
|
|
256
257
|
'POST /government/assignments': 'perm:government.manage',
|
|
257
258
|
'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
|
|
258
259
|
// The Board Room's first route (R08 of goal 1000069 — the R04-carried pin).
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -24,11 +24,16 @@ test('the ladder bands track exactly the thresholds the synthesis names', () =>
|
|
|
24
24
|
assert.equal(recommendedCharter(2).key, 'monarchy', 'this project today: two builders → the founding monarchy');
|
|
25
25
|
assert.equal(recommendedCharter(3).key, 'direct-democracy', 'the circle rung starts at 3');
|
|
26
26
|
assert.equal(recommendedCharter(8).key, 'direct-democracy');
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// ~9 and ~150 are where the ladder's own shapes (Presidential, Federation)
|
|
28
|
+
// cannot be adopted yet, so the recommendation names the nearest form that
|
|
29
|
+
// can — and says what is coming in `later` (task 1004400).
|
|
30
|
+
assert.equal(recommendedCharter(9).key, 'timocratic-republic', '~9: the circle gets too big');
|
|
31
|
+
assert.equal(recommendedCharter(9).later.key, 'presidential');
|
|
32
|
+
assert.equal(recommendedCharter(49).key, 'timocratic-republic');
|
|
29
33
|
assert.equal(recommendedCharter(50).key, 'foundation', '~50: written charter + council');
|
|
30
34
|
assert.equal(recommendedCharter(149).key, 'foundation');
|
|
31
|
-
assert.equal(recommendedCharter(150).key, '
|
|
35
|
+
assert.equal(recommendedCharter(150).key, 'foundation', '~150: a council carries it until federation exists');
|
|
36
|
+
assert.equal(recommendedCharter(150).later.key, 'federation');
|
|
32
37
|
assert.equal(recommendedCharter(0).key, 'monarchy', 'a zero/unknown headcount fails to the founding shape');
|
|
33
38
|
assert.equal(recommendedCharter('nonsense').key, 'monarchy');
|
|
34
39
|
});
|
|
@@ -74,7 +79,7 @@ test('the library presents all seven structures with honest selectable marks (cl
|
|
|
74
79
|
const entry = b.split('{ key:')[0];
|
|
75
80
|
assert.ok(entry.includes('how:'), 'a selectable structure names its configuration');
|
|
76
81
|
}
|
|
77
|
-
assert.match(client, /recommended
|
|
82
|
+
assert.match(client, /recommended for this type and size/, 'the one live datum is rendered');
|
|
78
83
|
assert.match(client, /c\.key === recommendedKey/, "the pill is driven by the SERVER's recommendation, never re-derived client-side");
|
|
79
84
|
for (const t of ['Locked', 'Laddered', 'Scheduled']) {
|
|
80
85
|
assert.ok(client.includes(`name: '${t}'`), `trajectory ${t} present`);
|
|
@@ -171,7 +176,8 @@ test('the recommendation names a charter the config can actually express', () =>
|
|
|
171
176
|
const rec = recommendedCharter(n);
|
|
172
177
|
const charter = by[rec.key];
|
|
173
178
|
assert.ok(charter, `headcount ${n} recommends "${rec.key}", which is not in the library`);
|
|
174
|
-
|
|
179
|
+
assert.ok(charter.selectable, `headcount ${n} recommends "${rec.key}", which cannot be adopted (task 1004400)`);
|
|
180
|
+
{
|
|
175
181
|
assert.ok(charter.predicate !== undefined,
|
|
176
182
|
`headcount ${n} recommends selectable "${rec.key}" — it must carry a checkable predicate`);
|
|
177
183
|
}
|
|
@@ -82,7 +82,10 @@ test('the read rides the vote atom, and the client renders the SERVER value (sou
|
|
|
82
82
|
assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/constitution'],
|
|
83
83
|
`${rrc.PERM_PIN_PREFIX}board.vote.cast`);
|
|
84
84
|
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
85
|
-
|
|
85
|
+
// Since task 1004400 the path may carry ?type= / &headcount= (advice-only
|
|
86
|
+
// inputs for the recommendation), so constitutionPath() builds it.
|
|
87
|
+
assert.match(client, /fetchJson\(constitutionPath\(\)\)/, 'the panel loads the resolved value');
|
|
88
|
+
assert.match(client, /`\/government\/constitution\$\{qs/, 'from the constitution read, nowhere else');
|
|
86
89
|
assert.ok(!/'first_ratifier'[^]*hardcod/i.test(client), 'sanity');
|
|
87
90
|
assert.match(client, /loadConstitution\(\)/, 'the panel loads for everyone the page admits');
|
|
88
91
|
});
|