@bongos/core 1.20.52 → 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 +61 -36
- 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 +37 -27
- package/docs/copy-registry.json +119 -29
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -46
- package/migrations/core_267_page_tweak_author_of_record.sql +41 -0
- 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/builders.js +3 -1
- package/modules/hall-ui/public/government.js +125 -2
- package/modules/lifecycle/db-analytics.js +6 -1
- package/modules/lifecycle/db-ship.js +10 -3
- package/modules/lifecycle/page-tweak-artist.js +86 -0
- package/modules/lifecycle/page-tweak-reads.js +7 -5
- package/modules/lifecycle/routes/tasks.js +3 -0
- package/modules/sessions/db.js +8 -0
- package/modules/status-ui/public/status.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -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
- package/tests/page_tweak_artist.mjs +236 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- core_267 — an approved page tweak already shipped names its artist (task 1004432,
|
|
2
|
+
-- WA6.RP02, goal 1000095)
|
|
3
|
+
--
|
|
4
|
+
-- From this release, shipTask (modules/lifecycle/db-ship.js) writes tasks.shipped_by
|
|
5
|
+
-- as the round's ARTIST when a page tweak ships, instead of the session that applied
|
|
6
|
+
-- it. Everything that counts or lists shipped work reads shipped_by (the profile,
|
|
7
|
+
-- the shipped count, the activity log, the status page's recent list, streaks,
|
|
8
|
+
-- achievements, the hub rollup), so that one write moves every one of them.
|
|
9
|
+
--
|
|
10
|
+
-- The rounds that shipped BEFORE this release still name their applier, so on every
|
|
11
|
+
-- one of those surfaces the artist who wrote the page is absent and the applier is
|
|
12
|
+
-- credited with work that was not theirs. This brings them in line, once.
|
|
13
|
+
--
|
|
14
|
+
-- The artist is page-tweak-artist.js's rule, spelled out here because a migration
|
|
15
|
+
-- cannot call it: the builder of the round's LATEST WEB claim (worktree_name and
|
|
16
|
+
-- creator_session_id both NULL), the same person economy's awardArtistPageCredit
|
|
17
|
+
-- already paid. A round with no web claim keeps its applier, exactly as the ship
|
|
18
|
+
-- path does. tests/page_tweak_artist.mjs holds this predicate to the helper's.
|
|
19
|
+
--
|
|
20
|
+
-- Data only, no column or constraint: forward-safe against a live database, and an
|
|
21
|
+
-- older release reading the rows just sees a different builder id. Idempotent: a
|
|
22
|
+
-- second run matches nothing, because the IS DISTINCT FROM guard skips rows that
|
|
23
|
+
-- already name their artist. Money is untouched: credit_log is not read or written,
|
|
24
|
+
-- and the artist was paid at the land.
|
|
25
|
+
|
|
26
|
+
UPDATE tasks t
|
|
27
|
+
SET shipped_by = w.builder_id
|
|
28
|
+
FROM (
|
|
29
|
+
SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
|
|
30
|
+
FROM claims c
|
|
31
|
+
JOIN tasks pt ON pt.id = c.task_id
|
|
32
|
+
WHERE pt.source = 'page-tweak'
|
|
33
|
+
AND pt.status = 'shipped'
|
|
34
|
+
AND c.worktree_name IS NULL AND c.creator_session_id IS NULL
|
|
35
|
+
ORDER BY c.task_id, c.claimed_at DESC, c.id DESC
|
|
36
|
+
) w
|
|
37
|
+
WHERE t.id = w.task_id
|
|
38
|
+
AND t.shipped_by IS DISTINCT FROM w.builder_id;
|
|
39
|
+
|
|
40
|
+
INSERT INTO schema_migrations (version) VALUES ('core_267_page_tweak_author_of_record')
|
|
41
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -60,6 +60,7 @@ const { describeMembership } = require('./membership-predicate');
|
|
|
60
60
|
// The genesis charter + legislation vocabulary (task 1004427): the real form
|
|
61
61
|
// and variable names, and the name a constitution reads as.
|
|
62
62
|
const charter = require('./charter');
|
|
63
|
+
const recommendations = require('./recommendations');
|
|
63
64
|
|
|
64
65
|
// ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
|
|
65
66
|
// Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
|
|
@@ -1149,25 +1150,13 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
|
|
|
1149
1150
|
// day — the §7 chain, auditable end to end.
|
|
1150
1151
|
// ── the charter ladder's recommendation (BV1.R19, criterion C5) ─────────────
|
|
1151
1152
|
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
function recommendedCharter(headcount) {
|
|
1160
|
-
const n = Number(headcount) || 0;
|
|
1161
|
-
if (n <= 2) return { key: 'monarchy', rung: 'solo/pair', why: 'informal coordination has nothing to break yet — the founding monarchy is the honest shape' };
|
|
1162
|
-
// "all trusted members" was not expressible under the pre-1003094 exact-match
|
|
1163
|
-
// grammar, so at this headcount the library recommended a charter it could not
|
|
1164
|
-
// state (task 1003095). It is expressible now — `rank:xenos+` seats every
|
|
1165
|
-
// builder — and the wording says the thing the predicate actually does rather
|
|
1166
|
-
// than a phrase with no predicate behind it.
|
|
1167
|
-
if (n <= 8) return { key: 'direct-democracy', rung: 'circle (3–8)', why: 'a consent circle of every builder (rank:xenos+) — the rung this ladder was written on' };
|
|
1168
|
-
if (n <= 49) return { key: 'presidential', rung: '~9: name an executive', why: 'past ~9, naming an executive beats everyone coordinating with everyone' };
|
|
1169
|
-
if (n <= 149) return { key: 'foundation', rung: '~50: written charter + council', why: 'past ~50, the charter must be written down and a council carries it' };
|
|
1170
|
-
return { key: 'federation', rung: '~150: adjudication separates — federate beyond', why: 'past ~150, one body cannot both govern and adjudicate; federate' };
|
|
1153
|
+
// Moved to recommendations.js (task 1004400), which reads project TYPE as well
|
|
1154
|
+
// as size and resolves every size to a form that can actually be adopted — the
|
|
1155
|
+
// ladder used to name Presidential at ~9 and Federation past ~150, neither of
|
|
1156
|
+
// which this system can express. Kept here under its old name because the
|
|
1157
|
+
// constitution view and its tests read it from board.js.
|
|
1158
|
+
function recommendedCharter(headcount, type) {
|
|
1159
|
+
return recommendations.recommendedCharter(headcount, type);
|
|
1171
1160
|
}
|
|
1172
1161
|
|
|
1173
1162
|
// ── the divergence check (task 1003739, ADR 0268) ───────────────────────────
|
|
@@ -1219,7 +1208,7 @@ function constitutionDivergence(liveBoard, history) {
|
|
|
1219
1208
|
});
|
|
1220
1209
|
}
|
|
1221
1210
|
|
|
1222
|
-
async function constitutionView({ historyLimit = 50 } = {}) {
|
|
1211
|
+
async function constitutionView({ historyLimit = 50, type = null, headcount: askedHeadcount = null } = {}) {
|
|
1223
1212
|
const { board: live } = loadGovernmentConfig();
|
|
1224
1213
|
const [rows, headcount] = await Promise.all([
|
|
1225
1214
|
db.listAmendmentHistory(historyLimit),
|
|
@@ -1242,7 +1231,12 @@ async function constitutionView({ historyLimit = 50 } = {}) {
|
|
|
1242
1231
|
board: live,
|
|
1243
1232
|
history,
|
|
1244
1233
|
headcount,
|
|
1245
|
-
recommended: recommendedCharter(headcount),
|
|
1234
|
+
recommended: recommendedCharter(askedHeadcount ?? headcount, type),
|
|
1235
|
+
// Both recommendations — the charter AND the ranks — for the project type
|
|
1236
|
+
// and size asked about (task 1004400). The size defaults to the live
|
|
1237
|
+
// headcount; the type defaults to "not sure", because a running project
|
|
1238
|
+
// does not record the type it declared at creation.
|
|
1239
|
+
recommendation: recommendations.recommendationsFor({ type, headcount: askedHeadcount ?? headcount }),
|
|
1246
1240
|
// null when the constitution in force IS the one last ratified, which is
|
|
1247
1241
|
// the healthy answer and the one the hall renders nothing for. Computed
|
|
1248
1242
|
// from the history already in hand — no extra query, and no second read
|
|
@@ -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' });
|
|
@@ -762,7 +762,9 @@
|
|
|
762
762
|
if (!tbody) return;
|
|
763
763
|
const slice = window.OTBKit.paginate(acclaimState.tasks, acclaimPager);
|
|
764
764
|
tbody.innerHTML = slice.map((t) => {
|
|
765
|
-
|
|
765
|
+
// task 1004432: an approved page is its artist's; the applying session rides along as a helper.
|
|
766
|
+
const helperName = t.helper_display_name || (t.helper_login ? `@${t.helper_login}` : '');
|
|
767
|
+
const by = escapeHtml(t.display_name || (t.github_login ? `@${t.github_login}` : `builder #${t.shipped_by}`)) + (helperName ? ` <span class="acclaim__evidence">with help from ${escapeHtml(helperName)}</span>` : '');
|
|
766
768
|
const net = Number(t.net_votes) || 0;
|
|
767
769
|
const netCls = signClasses(net, 'acclaim__net');
|
|
768
770
|
const netText = net > 0 ? `+${net}` : `${net}`;
|
|
@@ -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; }
|
|
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
|
|
|
14
14
|
const { pool, withTx } = api;
|
|
15
15
|
const { SMOKE_NOT_LIKE, gradePassCount, gradeQualityWhere, median } = require('./db-shared.js');
|
|
16
16
|
const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
|
|
17
|
+
const { helperJoinSql } = require('./page-tweak-artist.js');
|
|
17
18
|
|
|
18
19
|
// -------------------------------------------------------------------------
|
|
19
20
|
// Cost log + summary carved into modules/economy/cost.js (BV1.R77 / ADR 0093
|
|
@@ -47,9 +48,13 @@ async function recentShippedTasks(limit = 30, { days = null } = {}) {
|
|
|
47
48
|
CASE WHEN t.security_sensitive THEN NULL ELSE t.visual_url END AS visual_url, CASE WHEN t.security_sensitive THEN NULL ELSE t.visual_alt END AS visual_alt,
|
|
48
49
|
${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
|
|
49
50
|
t.est_minutes, t.actual_minutes,
|
|
50
|
-
b.github_login, b.display_name
|
|
51
|
+
b.github_login, b.display_name,
|
|
52
|
+
-- task 1004432: an approved page is its artist's (shipped_by); the session
|
|
53
|
+
-- that applied it rides along as a helper, so a reader still sees both.
|
|
54
|
+
helper.helper_login, helper.helper_display_name
|
|
51
55
|
FROM tasks t
|
|
52
56
|
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
57
|
+
${helperJoinSql('t')}
|
|
53
58
|
WHERE ${whereClause}
|
|
54
59
|
ORDER BY t.shipped_at DESC NULLS LAST
|
|
55
60
|
LIMIT $1`,
|
|
@@ -19,6 +19,7 @@ const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName
|
|
|
19
19
|
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
const { LIVE_STATUSES } = require('./dead-deps.js');
|
|
21
21
|
const { isPageTweak } = require('./page-tweak-hold.js');
|
|
22
|
+
const pageTweakArtist = require('./page-tweak-artist.js');
|
|
22
23
|
|
|
23
24
|
// shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
|
|
24
25
|
//
|
|
@@ -512,6 +513,9 @@ async function needsRebaseTasksForBuilder(builderId, client = pool) {
|
|
|
512
513
|
// The ship gate (task 1002483, ADR 0184) — see ./ship-assurance for the why.
|
|
513
514
|
const { normalizeShipAssurance, assertShipAssurance, recordShipAssurance, SHIP_ASSURANCE_KINDS } = require('./ship-assurance');
|
|
514
515
|
|
|
516
|
+
// The author of record (task 1004432): a page tweak names its ARTIST at this flip only.
|
|
517
|
+
const SHIPPED_BY_SQL = pageTweakArtist.shippedBySql('$2');
|
|
518
|
+
|
|
515
519
|
async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
516
520
|
const shipAssuranceValue = assertShipAssurance(landAssurance); // FAIL-CLOSED, ADR 0184
|
|
517
521
|
// #538 test seam (mirrors confirmTask): a caller-supplied pool is honored ONLY
|
|
@@ -523,7 +527,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
523
527
|
`UPDATE tasks
|
|
524
528
|
SET status = 'shipped',
|
|
525
529
|
shipped_at = now(),
|
|
526
|
-
shipped_by =
|
|
530
|
+
shipped_by = ${SHIPPED_BY_SQL},
|
|
527
531
|
needs_rebase = false,
|
|
528
532
|
needs_rebase_reason = NULL,
|
|
529
533
|
needs_rebase_at = NULL,
|
|
@@ -642,10 +646,13 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
642
646
|
// credit it keys off (breaking-the-100c-bar etc.) now lands here.
|
|
643
647
|
let justUnlocked = [];
|
|
644
648
|
const rewardPort = seams.resolveOptional('reward');
|
|
645
|
-
|
|
649
|
+
// A page tweak's ship count and credit moved to its artist (above), so the
|
|
650
|
+
// artist is whose achievements this ship can unlock; the applier's did not move.
|
|
651
|
+
const achievementBuilderId = isPageTweak(rows[0]) ? actualShipperId : creditedBuilderId;
|
|
652
|
+
if (rewardPort && rewardPort.reconcileBuilderAchievements && achievementBuilderId != null) {
|
|
646
653
|
await client.query('SAVEPOINT ach_reconcile_ship');
|
|
647
654
|
try {
|
|
648
|
-
justUnlocked = await rewardPort.reconcileBuilderAchievements(client,
|
|
655
|
+
justUnlocked = await rewardPort.reconcileBuilderAchievements(client, achievementBuilderId, { taskId });
|
|
649
656
|
await client.query('RELEASE SAVEPOINT ach_reconcile_ship');
|
|
650
657
|
} catch (_) {
|
|
651
658
|
await client.query('ROLLBACK TO SAVEPOINT ach_reconcile_ship');
|