@bongos/core 1.21.19 → 1.21.20
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 +35 -25
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +195 -196
- package/modules/autonomy/governor-docket.js +118 -4
- package/modules/economy/cost.js +36 -0
- package/modules/economy/governor-docket.js +81 -0
- package/modules/economy/routes/cost.js +2 -0
- package/modules/government/docket-floors.js +222 -0
- package/modules/government/docket-vocab.js +20 -0
- package/modules/government/docket.js +186 -5
- package/modules/hall-ui/public/street-inside.js +99 -43
- package/modules/ideas/governor-docket.js +42 -1
- package/modules/lifecycle/governor-docket.js +197 -2
- package/modules/security/routes/reports.js +16 -0
- package/modules/security/security.js +5 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/governor_docket.mjs +186 -2
- package/tests/hall_street_inside.mjs +97 -3
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
const api = require('../../src/module-api');
|
|
22
22
|
const db = require('./db');
|
|
23
23
|
const autonomyGate = require('../../scripts/gds/autonomy-gate');
|
|
24
|
+
const runnerHealth = require('./runner-health');
|
|
24
25
|
|
|
25
26
|
const POINT = 'governor.docket';
|
|
26
27
|
|
|
@@ -68,15 +69,123 @@ function moneyReading({ ceilingUsd, spentUsd }) {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// ── floors and the record (task 1004527) ─────────────────────────────────────
|
|
73
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
74
|
+
function dayWord(at) { const d = new Date(at); return Number.isNaN(d.getTime()) ? '' : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; }
|
|
75
|
+
const usd = (n) => `$${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
76
|
+
|
|
77
|
+
// Automation · Allowed goals. A line names its goal as `goal <id>`; the hall
|
|
78
|
+
// letters it with the goal's title from its own goal list (this module cannot
|
|
79
|
+
// read goals, ADR 0083), and the priority goal says so.
|
|
80
|
+
function allowedGoalsFloor(project) {
|
|
81
|
+
const goals = Array.isArray(project && project.goals) ? project.goals : [];
|
|
82
|
+
const lines = goals.slice(0, 4).map((g) => [`goal ${g.goal_id}`, Number(project.priority_goal_id) === Number(g.goal_id) ? 'first in line' : g.added_at ? `since ${dayWord(g.added_at)}` : '']);
|
|
83
|
+
if (goals.length > 4) lines.push([`and ${goals.length - 4} more`, '']);
|
|
84
|
+
return { count: String(goals.length), line: goals.length ? `${goals.length} ${goals.length === 1 ? 'goal' : 'goals'} it may build` : 'no goal allowed', lines: lines.length ? lines : [['It may not build any goal yet.', '']], hot: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Automation · Running now: runners checked in alive and working on a task.
|
|
88
|
+
function runningFloor(scopes, nowMs, describe) {
|
|
89
|
+
const working = [];
|
|
90
|
+
for (const b of scopes || []) {
|
|
91
|
+
for (const r of b.runners || []) {
|
|
92
|
+
const h = describe(r, { nowMs });
|
|
93
|
+
if (h && h.ok && h.state !== 'never_seen' && r.working_task_id) working.push({ who: b.display_name || b.github_login || `builder ${b.builder_id}`, task: r.working_task_id });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
count: String(working.length),
|
|
98
|
+
line: working.length ? `${working.length} ${working.length === 1 ? 'build' : 'builds'} in progress` : 'nothing building now',
|
|
99
|
+
lines: working.slice(0, 4).map((w) => [`task ${w.task}`, w.who]),
|
|
100
|
+
hot: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Automation · Spending limit: the month's unattended ceiling and what is used.
|
|
105
|
+
function spendingLimitFloor({ ceilingUsd, spentUsd }) {
|
|
106
|
+
const ceiling = Number(ceilingUsd) || 0;
|
|
107
|
+
const spent = Math.max(0, Number(spentUsd) || 0);
|
|
108
|
+
return {
|
|
109
|
+
count: usd(ceiling),
|
|
110
|
+
line: `${usd(ceiling)} a month · ${usd(spent)} used`,
|
|
111
|
+
lines: [['It stops for the month when the limit is reached.', 'rule'], [`${usd(Math.max(0, ceiling - spent))} left this month.`, ''], ['A limit for each day is coming; today the limit is for the month.', 'coming']],
|
|
112
|
+
hot: ceiling > 0 && (ceiling - spent) / ceiling < 0.2,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Automation · Reports: what the unattended routines decided, newest first, in
|
|
117
|
+
// words (a routine key and a go/no-go are the gate's vocabulary, not a reader's).
|
|
118
|
+
const DECISION_WORDS = Object.freeze({ go: 'went ahead', 'no-go': 'held back' });
|
|
119
|
+
function routineWord(routine) {
|
|
120
|
+
const r = String(routine || '');
|
|
121
|
+
if (/builder/i.test(r)) return 'The builder';
|
|
122
|
+
if (/idea|triage/i.test(r)) return 'Idea sorting';
|
|
123
|
+
if (/simplify/i.test(r)) return 'The tidy-up pass';
|
|
124
|
+
if (/code-?review/i.test(r)) return 'The code review';
|
|
125
|
+
return r ? `The ${r} routine` : 'A routine';
|
|
126
|
+
}
|
|
127
|
+
function reportsFloor(runs, now) {
|
|
128
|
+
const week = (runs || []).filter((r) => r.created_at && now - Date.parse(r.created_at) <= 7 * 86400000);
|
|
129
|
+
const said = (r) => `${routineWord(r.routine)} ${DECISION_WORDS[r.decision] || `decided ${r.decision}`}${r.reason ? `: ${r.reason}` : ''}.`;
|
|
130
|
+
return {
|
|
131
|
+
count: String(week.length),
|
|
132
|
+
line: week.length ? `${week.length} ${week.length === 1 ? 'report' : 'reports'} this week` : 'no report this week',
|
|
133
|
+
lines: (runs || []).slice(0, 4).map((r) => [said(r), dayWord(r.created_at)]),
|
|
134
|
+
hot: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The record: the switch's last change (the only one the fence row keeps).
|
|
139
|
+
function fenceHistory(project) {
|
|
140
|
+
if (!project || !project.updated_at) return [];
|
|
141
|
+
return [{ id: 'switch', title: project.enabled ? 'Unattended building switched on' : `Unattended building switched off${project.paused_reason ? `: ${project.paused_reason}` : ''}`, at: project.updated_at, outcome: 'the switch' }];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// One read per docket request: the collector hands every read a per-request
|
|
145
|
+
// `once(key, fn)` (docket.js buildDocket), so two floors that need the same rows
|
|
146
|
+
// share one query. Without it (a direct call) the read simply runs.
|
|
147
|
+
const shared = (once, key, fn) => (typeof once === 'function' ? once(key, fn) : fn());
|
|
148
|
+
const spendFor = (reward, once) => shared(once, 'autonomy.spendMtd', () => reward.autonomousApiSpendMtd());
|
|
149
|
+
const fenceFor = (once) => shared(once, 'autonomy.fence', () => db.readFence());
|
|
150
|
+
|
|
151
|
+
function contributeAutonomyFloors() {
|
|
152
|
+
api.contribute(POINT, {
|
|
153
|
+
floor: 'automation.allowed_goals', gate: { anyBuilder: true },
|
|
154
|
+
read: async (_ctx, { once } = {}) => { const p = await fenceFor(once); if (!p) throw new Error('fence_uninitialised'); return allowedGoalsFloor(p); },
|
|
155
|
+
});
|
|
156
|
+
// GET /autonomy/runners/all is the owner's read of every runner.
|
|
157
|
+
api.contribute(POINT, {
|
|
158
|
+
floor: 'automation.running', gate: { permission: 'autonomy.fence.manage' },
|
|
159
|
+
read: async (_ctx, { now } = {}) => runningFloor(await db.readAllRunnerScopes(), now || Date.now(), runnerHealth.describeRunner),
|
|
160
|
+
});
|
|
161
|
+
api.contribute(POINT, {
|
|
162
|
+
floor: 'automation.spending_limit', gate: { permission: 'autonomy.precheck' },
|
|
163
|
+
read: async (_ctx, { once } = {}) => {
|
|
164
|
+
const reward = api.resolveOptional('reward');
|
|
165
|
+
if (!reward || typeof reward.autonomousApiSpendMtd !== 'function') throw new Error('economy_off');
|
|
166
|
+
return spendingLimitFloor({ ceilingUsd: autonomyGate.ceilingUsd(), spentUsd: await spendFor(reward, once) });
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
api.contribute(POINT, {
|
|
170
|
+
floor: 'automation.reports', gate: { permission: 'autonomy.precheck' },
|
|
171
|
+
read: async (_ctx, { now } = {}) => reportsFloor(await db.readAutonomyRuns({ limit: 50 }), now || Date.now()),
|
|
172
|
+
});
|
|
173
|
+
api.contribute(POINT, {
|
|
174
|
+
history: 'switch', building: 'automation', gate: { anyBuilder: true },
|
|
175
|
+
read: async (_ctx, { once } = {}) => fenceHistory(await fenceFor(once)),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
71
179
|
function contributeAutonomyToDocket() {
|
|
180
|
+
contributeAutonomyFloors();
|
|
72
181
|
// GET /autonomy/fence is any builder's read; the read itself fails on a missing
|
|
73
182
|
// fence row rather than fabricating "off" (that route's rule).
|
|
74
183
|
api.contribute(POINT, {
|
|
75
184
|
source: 'autobongos_fence',
|
|
76
185
|
building: 'automation',
|
|
77
186
|
gate: { anyBuilder: true },
|
|
78
|
-
read: async (ctx) => {
|
|
79
|
-
const project = await
|
|
187
|
+
read: async (ctx, { once } = {}) => {
|
|
188
|
+
const project = await fenceFor(once);
|
|
80
189
|
if (!project) throw new Error('fence_uninitialised');
|
|
81
190
|
return fenceDocketMatters(project, ctx.has('autonomy.fence.manage'));
|
|
82
191
|
},
|
|
@@ -86,10 +195,10 @@ function contributeAutonomyToDocket() {
|
|
|
86
195
|
api.contribute(POINT, {
|
|
87
196
|
reading: 'money',
|
|
88
197
|
gate: { permission: 'autonomy.precheck' },
|
|
89
|
-
read: async () => {
|
|
198
|
+
read: async (_ctx, { once } = {}) => {
|
|
90
199
|
const reward = api.resolveOptional('reward');
|
|
91
200
|
if (!reward || typeof reward.autonomousApiSpendMtd !== 'function') throw new Error('economy_off');
|
|
92
|
-
return moneyReading({ ceilingUsd: autonomyGate.ceilingUsd(), spentUsd: await reward
|
|
201
|
+
return moneyReading({ ceilingUsd: autonomyGate.ceilingUsd(), spentUsd: await spendFor(reward, once) });
|
|
93
202
|
},
|
|
94
203
|
});
|
|
95
204
|
}
|
|
@@ -98,4 +207,9 @@ module.exports = {
|
|
|
98
207
|
contributeAutonomyToDocket,
|
|
99
208
|
fenceDocketMatters,
|
|
100
209
|
moneyReading,
|
|
210
|
+
allowedGoalsFloor,
|
|
211
|
+
runningFloor,
|
|
212
|
+
spendingLimitFloor,
|
|
213
|
+
reportsFloor,
|
|
214
|
+
fenceHistory,
|
|
101
215
|
};
|
package/modules/economy/cost.js
CHANGED
|
@@ -88,6 +88,41 @@ async function autonomousApiSpendMtd() {
|
|
|
88
88
|
return Number(rows[0]?.usd) || 0;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// The Treasury's three spending lines, month by month (task 1004527, the
|
|
92
|
+
// Governor City): rewards paid to builders (credit_log, 1 credit = $1), what the
|
|
93
|
+
// grader cost (cost_log source 'grader') and hosting (categories infra, compute,
|
|
94
|
+
// domain). The same ledgers GET /public/cost-summary already publishes, cut the
|
|
95
|
+
// one way the Treasury building reads them. Newest month first, bounded.
|
|
96
|
+
const HOSTING_CATEGORIES = Object.freeze(['infra', 'compute', 'domain']);
|
|
97
|
+
async function treasuryMonths({ months = 6 } = {}) {
|
|
98
|
+
const n = Math.max(1, Math.min(24, Number(months) || 6));
|
|
99
|
+
const [spend, rewards] = await Promise.all([
|
|
100
|
+
pool.query(
|
|
101
|
+
`SELECT to_char(recorded_at, 'YYYY-MM') AS month,
|
|
102
|
+
COALESCE(SUM(amount_usd) FILTER (WHERE source = 'grader'), 0) AS grading_usd,
|
|
103
|
+
COALESCE(SUM(amount_usd) FILTER (WHERE category = ANY($2::text[])), 0) AS hosting_usd
|
|
104
|
+
FROM cost_log
|
|
105
|
+
WHERE recorded_at >= date_trunc('month', now()) - (($1::int - 1) * interval '1 month')
|
|
106
|
+
GROUP BY month`,
|
|
107
|
+
[n, HOSTING_CATEGORIES]
|
|
108
|
+
),
|
|
109
|
+
pool.query(
|
|
110
|
+
`SELECT to_char(recorded_at, 'YYYY-MM') AS month,
|
|
111
|
+
COALESCE(SUM(delta), 0) AS credits, COUNT(DISTINCT builder_id)::int AS builders
|
|
112
|
+
FROM credit_log
|
|
113
|
+
WHERE delta > 0
|
|
114
|
+
AND recorded_at >= date_trunc('month', now()) - (($1::int - 1) * interval '1 month')
|
|
115
|
+
GROUP BY month`,
|
|
116
|
+
[n]
|
|
117
|
+
),
|
|
118
|
+
]);
|
|
119
|
+
const by = new Map();
|
|
120
|
+
const row = (m) => { if (!by.has(m)) by.set(m, { month: m, grading_usd: 0, hosting_usd: 0, rewards_credits: 0, rewarded_builders: 0 }); return by.get(m); };
|
|
121
|
+
for (const r of spend.rows) Object.assign(row(String(r.month)), { grading_usd: Number(r.grading_usd) || 0, hosting_usd: Number(r.hosting_usd) || 0 });
|
|
122
|
+
for (const r of rewards.rows) Object.assign(row(String(r.month)), { rewards_credits: Number(r.credits) || 0, rewarded_builders: Number(r.builders) || 0 });
|
|
123
|
+
return [...by.values()].sort((a, b) => (a.month < b.month ? 1 : -1));
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
// V3.R84 (#302): Archon sets (or clears) a builder's monthly budget override.
|
|
92
127
|
// usd === null clears the override (the builder falls back to the project
|
|
93
128
|
// default). Returns the updated row's id + budget, or null if no such builder.
|
|
@@ -527,6 +562,7 @@ async function costCalibrationSummary() {
|
|
|
527
562
|
module.exports = {
|
|
528
563
|
getBuilderCostStatus,
|
|
529
564
|
autonomousApiSpendMtd,
|
|
565
|
+
treasuryMonths,
|
|
530
566
|
setBuilderMonthlyBudget,
|
|
531
567
|
logCost,
|
|
532
568
|
reconcileSessionCosts,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/governor-docket.js — the Treasury's floors and record for the
|
|
4
|
+
// governor's docket (task 1004527, goal 1000125; collector:
|
|
5
|
+
// modules/government/docket.js).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Inside the Treasury building three floors are the project's real
|
|
8
|
+
// spending lines — rewards paid for shipped work, the AI grader, and hosting —
|
|
9
|
+
// and its record drawers are those same lines month by month. They come from
|
|
10
|
+
// the two ledgers this module owns (credit_log, cost_log) through ONE bounded
|
|
11
|
+
// read, cost.treasuryMonths. The ledgers are already published unauthenticated
|
|
12
|
+
// by GET /public/cost-summary, so the floors are any builder's to read.
|
|
13
|
+
//
|
|
14
|
+
// Reserve and spending votes are not here: Reserve is the `money` reading
|
|
15
|
+
// (autonomy's unattended ceiling), and the project treasury with its own budget
|
|
16
|
+
// and votes is an engine that does not exist yet (the docket says `coming`).
|
|
17
|
+
//
|
|
18
|
+
// Contributed from the cost route factory (a module never requires a sibling,
|
|
19
|
+
// ADR 0083). Pure mappers exported for tests.
|
|
20
|
+
|
|
21
|
+
const api = require('../../src/module-api');
|
|
22
|
+
const cost = require('./cost');
|
|
23
|
+
|
|
24
|
+
const POINT = 'governor.docket';
|
|
25
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
26
|
+
|
|
27
|
+
function usd(n) {
|
|
28
|
+
const v = Math.round(Number(n) || 0);
|
|
29
|
+
return `$${v.toLocaleString('en-US')}`;
|
|
30
|
+
}
|
|
31
|
+
function monthKey(now) { const d = new Date(now); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; }
|
|
32
|
+
function monthWord(key) { const m = Number(String(key).slice(5, 7)); return MONTHS[m - 1] || String(key); }
|
|
33
|
+
|
|
34
|
+
// One spending line's floor from the month rows. Pure.
|
|
35
|
+
function lineFloor(months, now, field, { what, meta }) {
|
|
36
|
+
const rows = Array.isArray(months) ? months : [];
|
|
37
|
+
const cur = rows.find((r) => r.month === monthKey(now));
|
|
38
|
+
const prev = rows.find((r) => r.month < monthKey(now));
|
|
39
|
+
const v = cur ? Number(cur[field]) || 0 : 0;
|
|
40
|
+
const lines = [[what, meta]];
|
|
41
|
+
if (prev) lines.push([`${usd(prev[field])} in ${monthWord(prev.month)}.`, 'last month']);
|
|
42
|
+
return { count: usd(v), line: `${usd(v)} this month`, lines, hot: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rewardsFloor(months, now) {
|
|
46
|
+
const f = lineFloor(months, now, 'rewards_credits', { what: 'Paid to builders for shipped work, one credit to the dollar.', meta: 'each ship' });
|
|
47
|
+
const cur = (months || []).find((r) => r.month === monthKey(now));
|
|
48
|
+
const n = cur ? cur.rewarded_builders : 0;
|
|
49
|
+
return { ...f, line: `${f.count} this month · ${n} ${n === 1 ? 'builder' : 'builders'}` };
|
|
50
|
+
}
|
|
51
|
+
const gradingFloor = (months, now) => lineFloor(months, now, 'grading_usd', { what: 'The reviewer that grades every ship.', meta: 'each ship' });
|
|
52
|
+
const hostingFloor = (months, now) => lineFloor(months, now, 'hosting_usd', { what: 'Servers, storage and the domain.', meta: 'monthly' });
|
|
53
|
+
|
|
54
|
+
// The record: each month's three lines. Pure.
|
|
55
|
+
function treasuryHistory(months) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const r of months || []) {
|
|
58
|
+
const at = `${r.month}-01T00:00:00.000Z`;
|
|
59
|
+
if (r.rewards_credits > 0) out.push({ id: `rewards:${r.month}`, title: `Paid ${usd(r.rewards_credits)} to ${r.rewarded_builders} ${r.rewarded_builders === 1 ? 'builder' : 'builders'}`, at, outcome: 'rewards' });
|
|
60
|
+
if (r.grading_usd > 0) out.push({ id: `grading:${r.month}`, title: `AI grading cost ${usd(r.grading_usd)}`, at, outcome: 'grading' });
|
|
61
|
+
if (r.hosting_usd > 0) out.push({ id: `hosting:${r.month}`, title: `Hosting cost ${usd(r.hosting_usd)}`, at, outcome: 'hosting' });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One read per docket request: the collector hands every read a per-request
|
|
67
|
+
// `once(key, fn)` (docket.js buildDocket), so two floors that need the same rows
|
|
68
|
+
// share one query. Without it (a direct call) the read simply runs.
|
|
69
|
+
const shared = (once, key, fn) => (typeof once === 'function' ? once(key, fn) : fn());
|
|
70
|
+
const monthsFor = (once) => shared(once, 'economy.treasuryMonths', () => cost.treasuryMonths({ months: 6 }));
|
|
71
|
+
|
|
72
|
+
function contributeEconomyToDocket() {
|
|
73
|
+
const any = { anyBuilder: true };
|
|
74
|
+
const at = (now) => now || Date.now();
|
|
75
|
+
api.contribute(POINT, { floor: 'treasury.rewards', gate: any, read: async (_c, { now, once } = {}) => rewardsFloor(await monthsFor(once), at(now)) });
|
|
76
|
+
api.contribute(POINT, { floor: 'treasury.grading', gate: any, read: async (_c, { now, once } = {}) => gradingFloor(await monthsFor(once), at(now)) });
|
|
77
|
+
api.contribute(POINT, { floor: 'treasury.hosting', gate: any, read: async (_c, { now, once } = {}) => hostingFloor(await monthsFor(once), at(now)) });
|
|
78
|
+
api.contribute(POINT, { history: 'treasury', building: 'treasury', gate: any, read: async (_c, { once } = {}) => treasuryHistory(await monthsFor(once)) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { contributeEconomyToDocket, rewardsFloor, gradingFloor, hostingFloor, treasuryHistory };
|
|
@@ -25,6 +25,8 @@ const { validateOrRespond, LIMITS } = api;
|
|
|
25
25
|
const COST_CATEGORIES = ['api', 'compute', 'infra', 'domain', 'art', 'other'];
|
|
26
26
|
|
|
27
27
|
module.exports = function buildCostRouter() {
|
|
28
|
+
// The Treasury's floors and record in the Governor City (task 1004527).
|
|
29
|
+
require('../governor-docket').contributeEconomyToDocket();
|
|
28
30
|
const router = express.Router();
|
|
29
31
|
|
|
30
32
|
// rank: any-builder — log cost on own claim (idempotent via source/source_ref).
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/docket-floors.js — the floors and the record this module
|
|
4
|
+
// answers for the governor's docket (task 1004527, goal 1000125).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Inside each building of the Governor City a floor is either a queue of
|
|
7
|
+
// matters (a docket source), a reading, or a STATE: who sits on the council,
|
|
8
|
+
// what each section of the charter says, the ranks and who holds them. The
|
|
9
|
+
// government module owns those states, so it answers them here, from the reads
|
|
10
|
+
// docketFor() has already made once for the request (the board block in force,
|
|
11
|
+
// the decision-rules table, the seated board, the founding stages, the ranks and
|
|
12
|
+
// the closed sittings). Every builder below is PURE — a missing input gives
|
|
13
|
+
// `null`, which the collector reports as `unavailable`, never as an empty floor.
|
|
14
|
+
//
|
|
15
|
+
// NOTHING IS INVENTED. Each line is a sentence about a row that exists. Where
|
|
16
|
+
// the design names an engine this project does not have yet (seats with a term,
|
|
17
|
+
// elections and recalls, promotion by a vote, a written purpose), the floor
|
|
18
|
+
// says so in words and marks the line `coming`.
|
|
19
|
+
|
|
20
|
+
const { describeMembership } = require('./membership-predicate');
|
|
21
|
+
const decisionRules = require('./decision-rules');
|
|
22
|
+
|
|
23
|
+
const PASS_WORDS = Object.freeze({
|
|
24
|
+
first_ratifier: 'the first yes',
|
|
25
|
+
consent: 'no one objecting',
|
|
26
|
+
majority: 'more than half saying yes',
|
|
27
|
+
unanimous: 'everyone who votes saying yes',
|
|
28
|
+
});
|
|
29
|
+
const OUTCOME_WORDS = Object.freeze({ passed: 'passed', returned: 'returned', expired: 'ran out of time', withdrawn: 'withdrawn' });
|
|
30
|
+
const MAX_LINES = 4;
|
|
31
|
+
|
|
32
|
+
function personName(p) {
|
|
33
|
+
if (!p) return null;
|
|
34
|
+
return p.display_name || p.name || p.github_login || p.login || (p.builder_id != null ? `builder ${p.builder_id}` : null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 'Ana', 'Ana and Theo', 'Ana, Theo and Mira', 'Ana, Theo, Mira and 4 more'.
|
|
38
|
+
function namesLine(people, max = 3) {
|
|
39
|
+
const names = (people || []).map(personName).filter(Boolean);
|
|
40
|
+
if (!names.length) return '';
|
|
41
|
+
if (names.length === 1) return names[0];
|
|
42
|
+
if (names.length <= max) return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
43
|
+
return `${names.slice(0, max).join(', ')} and ${names.length - max} more`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sentence(s) {
|
|
47
|
+
const t = String(s || '').trim();
|
|
48
|
+
if (!t) return '';
|
|
49
|
+
return t.charAt(0).toUpperCase() + t.slice(1) + (/[.!?]$/.test(t) ? '' : '.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The words a decision row is read in: who, then how.
|
|
53
|
+
function decisionLine(d) {
|
|
54
|
+
if (!d) return null;
|
|
55
|
+
if (d.rule === 'vacant') return 'held by no one yet';
|
|
56
|
+
return `${d.who}${d.rule_words ? ` · ${d.rule_words}` : ''}`;
|
|
57
|
+
}
|
|
58
|
+
// Whether a building's row is still the one the project started with.
|
|
59
|
+
function decisionMeta(d) { return d && d.as_today ? 'as founded' : 'amended'; }
|
|
60
|
+
|
|
61
|
+
function decisionFor(gov, area) {
|
|
62
|
+
return (gov && Array.isArray(gov.decisions) ? gov.decisions : []).find((d) => d && d.area === area) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// §5 Money, §6 Review, §7 Release: one building's row, in a sentence.
|
|
66
|
+
function sectionFloor(gov, area) {
|
|
67
|
+
const d = decisionFor(gov, area);
|
|
68
|
+
if (!d) return null;
|
|
69
|
+
const line = decisionLine(d);
|
|
70
|
+
const text = d.rule === 'vacant'
|
|
71
|
+
? `${d.name} is held by no one yet. Its matters wait until an amendment seats someone.`
|
|
72
|
+
: `Held by ${d.who}${d.rule_words ? `: ${d.rule_words}` : ''}.`;
|
|
73
|
+
return { count: '', line, lines: [[text, decisionMeta(d)]], hot: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function councilFloor(gov, { short = false } = {}) {
|
|
77
|
+
if (!gov || !Array.isArray(gov.members) || !gov.board) return null;
|
|
78
|
+
const n = gov.members.length;
|
|
79
|
+
const pass = PASS_WORDS[gov.board.pass_rule];
|
|
80
|
+
const lines = [];
|
|
81
|
+
if (n) lines.push([`${sentence(namesLine(gov.members, 5))}`, 'seated now']);
|
|
82
|
+
else lines.push(['No one is seated on the council.', 'seated now']);
|
|
83
|
+
if (pass) lines.push([`A matter put to it passes on ${pass}.`, 'charter']);
|
|
84
|
+
lines.push(['Seats have no term yet. Elections and recalls are coming.', 'coming']);
|
|
85
|
+
return {
|
|
86
|
+
count: String(n),
|
|
87
|
+
line: short ? `${n} ${n === 1 ? 'seat' : 'seats'} · no term yet` : `${n} ${n === 1 ? 'seat' : 'seats'}${pass ? ` · passes on ${pass}` : ''}`,
|
|
88
|
+
lines,
|
|
89
|
+
hot: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function foundingFloor(gov) {
|
|
94
|
+
const stages = gov && gov.genesis && Array.isArray(gov.genesis.stages) ? gov.genesis.stages : null;
|
|
95
|
+
if (!stages) return null;
|
|
96
|
+
const word = (s) => s.state === 'not_put' ? 'not put yet' : s.state === 'sitting' ? 'being decided' : (OUTCOME_WORDS[s.state] || String(s.state));
|
|
97
|
+
const passed = stages.filter((s) => s.state === 'passed').length;
|
|
98
|
+
return {
|
|
99
|
+
count: '',
|
|
100
|
+
line: stages.length ? `${passed} of ${stages.length} founding steps passed` : 'no founding steps',
|
|
101
|
+
lines: stages.slice(0, MAX_LINES + 1).map((s) => [sentence(s.label), word(s)]),
|
|
102
|
+
hot: stages.some((s) => s.state === 'sitting'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function membershipFloor(gov) {
|
|
107
|
+
if (!gov || !gov.board) return null;
|
|
108
|
+
const who = describeMembership(gov.board.membership);
|
|
109
|
+
const people = decisionFor(gov, 'people');
|
|
110
|
+
const lines = [[who ? `The council is everyone who holds ${who}.` : 'Who sits on the council is not readable from the charter.', 'charter']];
|
|
111
|
+
if (people) lines.push([`Who joins, and their rank, is settled by ${people.who}${people.rule_words && people.rule !== 'vacant' ? `: ${people.rule_words}` : ''}.`, decisionMeta(people)]);
|
|
112
|
+
return { count: '', line: who ? `the council: ${who}` : 'who belongs, and how they join', lines, hot: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rulesSectionFloor(gov) {
|
|
116
|
+
if (!gov || !gov.board) return null;
|
|
117
|
+
const d = decisionFor(gov, 'rules');
|
|
118
|
+
const pass = PASS_WORDS[gov.board.pass_rule];
|
|
119
|
+
const lines = [];
|
|
120
|
+
if (pass) lines.push([`A change to the charter passes on ${pass}.`, 'charter']);
|
|
121
|
+
if (d) lines.push([`A standing rule is set by ${d.who}${d.rule_words && d.rule !== 'vacant' ? `: ${d.rule_words}` : ''}.`, decisionMeta(d)]);
|
|
122
|
+
if (!lines.length) return null;
|
|
123
|
+
return { count: '', line: d ? decisionLine(d) : 'how a rule passes', lines, hot: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function testersFloor(gov) {
|
|
127
|
+
const d = decisionFor(gov, 'review');
|
|
128
|
+
const holders = gov && gov.holders ? gov.holders.review : null;
|
|
129
|
+
if (!d || !Array.isArray(holders)) return null;
|
|
130
|
+
if (d.rule === 'vacant') return { count: '0', line: 'no seat yet', lines: [['No one holds the testers seat yet.', decisionMeta(d)]], hot: false };
|
|
131
|
+
const n = holders.length;
|
|
132
|
+
return {
|
|
133
|
+
count: String(n),
|
|
134
|
+
line: n ? namesLine(holders) : 'no one holds the seat',
|
|
135
|
+
lines: [[n ? `${sentence(namesLine(holders, 5)).replace(/\.$/, '')} may sign off finished work.` : 'No one may sign off finished work yet.', decisionMeta(d)], [sentence(d.rule_words || ''), 'rule']].filter((l) => l[0]),
|
|
136
|
+
hot: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ranksFloor(gov) {
|
|
141
|
+
if (!gov || !Array.isArray(gov.ranks)) return null;
|
|
142
|
+
const ranks = gov.ranks.slice().sort((a, b) => (b.holders || 0) - (a.holders || 0));
|
|
143
|
+
const people = decisionFor(gov, 'people');
|
|
144
|
+
const lines = ranks.slice(0, MAX_LINES).map((r) => [String(r.label || r.rank_key), `${r.holders || 0} ${(r.holders || 0) === 1 ? 'person' : 'people'}`]);
|
|
145
|
+
lines.push([`Ranks are granted by ${people ? people.who : 'whoever may manage ranks'}. Promotion by a vote is coming.`, 'coming']);
|
|
146
|
+
return { count: String(ranks.length), line: `${ranks.length} ${ranks.length === 1 ? 'rank' : 'ranks'}`, lines, hot: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Every floor this module answers, keyed as docket-vocab FLOORS names them.
|
|
150
|
+
const BUILDERS = Object.freeze({
|
|
151
|
+
'charter.founding': foundingFloor,
|
|
152
|
+
'charter.membership': membershipFloor,
|
|
153
|
+
'charter.council': (gov) => councilFloor(gov),
|
|
154
|
+
'charter.rules': rulesSectionFloor,
|
|
155
|
+
'charter.money': (gov) => sectionFloor(gov, 'treasury'),
|
|
156
|
+
'charter.review': (gov) => sectionFloor(gov, 'review'),
|
|
157
|
+
'charter.release': (gov) => sectionFloor(gov, 'release'),
|
|
158
|
+
'people.council': (gov) => councilFloor(gov, { short: true }),
|
|
159
|
+
'people.testers': testersFloor,
|
|
160
|
+
'people.ranks': ranksFloor,
|
|
161
|
+
'review.testers': testersFloor,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Ideas to ratify: the board's open full-idea sittings the viewer can see. Pure
|
|
165
|
+
// over the docket's own matters, so it can never disagree with the pins.
|
|
166
|
+
function ideasFloor(matters) {
|
|
167
|
+
const open = (matters || []).filter((m) => m.source === 'board' && m.short === 'idea to ratify' && m.state !== 'settled');
|
|
168
|
+
const n = open.length;
|
|
169
|
+
return {
|
|
170
|
+
count: String(n),
|
|
171
|
+
line: n ? `${n} full ${n === 1 ? 'idea' : 'ideas'} waiting for a vote` : 'no full idea waiting for a vote',
|
|
172
|
+
lines: open.slice(0, MAX_LINES).map((m) => [m.title, m.state === 'needs' ? 'needs you' : 'moving']),
|
|
173
|
+
hot: open.some((m) => m.state === 'needs'),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function firstLine(md, fallback) {
|
|
178
|
+
const line = String(md || '').split(/\r?\n/).map((s) => s.replace(/^[#>*\-\s]+/, '').trim()).find(Boolean);
|
|
179
|
+
if (!line) return fallback;
|
|
180
|
+
return line.length > 120 ? `${line.slice(0, 117)}…` : line;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The record: what this module has closed, as history rows. Pure.
|
|
184
|
+
// amendments db.listAmendmentHistory rows (closed amendment sittings)
|
|
185
|
+
// genesis genesisStageStatus().stages (closed founding steps)
|
|
186
|
+
// closed boardRoomView().closed (closed sittings of every subject)
|
|
187
|
+
// assignments db.listAssignments rows (who was given which rank, when)
|
|
188
|
+
function governmentHistory(gov) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const outcome = (o) => OUTCOME_WORDS[o] || (o ? String(o) : null);
|
|
191
|
+
for (const a of (gov && gov.amendments) || []) {
|
|
192
|
+
if (!a || !a.closed_at) continue;
|
|
193
|
+
out.push({ id: `amendment:${a.item_id}`, building: 'charter', at: a.closed_at, outcome: outcome(a.outcome),
|
|
194
|
+
title: a.kind === 'decision_rules' ? firstLine(a.rationale_md, 'A change to who decides at a building') : firstLine(a.rationale_md, 'A change to the charter') });
|
|
195
|
+
}
|
|
196
|
+
const stages = gov && gov.genesis && Array.isArray(gov.genesis.stages) ? gov.genesis.stages : [];
|
|
197
|
+
for (const s of stages) {
|
|
198
|
+
if (!s.closed_at) continue;
|
|
199
|
+
out.push({ id: `founding:${s.item_id}`, building: 'charter', at: s.closed_at, outcome: outcome(s.state), title: `Founding: ${s.label}` });
|
|
200
|
+
}
|
|
201
|
+
for (const item of (gov && gov.closed) || []) {
|
|
202
|
+
if (!item || !item.closed_at) continue;
|
|
203
|
+
const area = decisionRules.areaForSubjectType(item.subject_type);
|
|
204
|
+
const building = area || (item.subject_type === 'full_idea' ? 'planning' : null);
|
|
205
|
+
if (!building) continue; // amendments and founding steps are filed above
|
|
206
|
+
const title = (item.subject && item.subject.title) || (item.subject_type === 'full_idea' ? `Full idea ${item.subject_id}` : `A matter at ${building}`);
|
|
207
|
+
const o = item.subject_type === 'full_idea' && item.outcome === 'passed' ? 'ratified' : outcome(item.outcome);
|
|
208
|
+
out.push({ id: `board:${item.id}`, building, at: item.closed_at, outcome: o, title });
|
|
209
|
+
}
|
|
210
|
+
for (const r of (gov && gov.assignments) || []) {
|
|
211
|
+
if (!r || !r.assigned_at || r.is_system) continue;
|
|
212
|
+
const who = r.display_name || r.github_login || `builder ${r.builder_id}`;
|
|
213
|
+
out.push({ id: `rank:${r.builder_id}:${r.rank_key}`, building: 'people', at: r.assigned_at, outcome: 'granted', title: `${who} holds ${r.rank_label || r.rank_key}` });
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
BUILDERS,
|
|
220
|
+
ideasFloor,
|
|
221
|
+
governmentHistory,
|
|
222
|
+
};
|
|
@@ -71,6 +71,25 @@ const READINGS = Object.freeze([
|
|
|
71
71
|
Object.freeze({ key: 'rules', label: 'rules at work', building: 'rules' }),
|
|
72
72
|
]);
|
|
73
73
|
|
|
74
|
+
// The floors inside the buildings that are not a queue of matters or a reading
|
|
75
|
+
// (task 1004527): a state, a roster or a month's figure, each read live by the
|
|
76
|
+
// module that owns it. The docket reports a status for every one, so a floor
|
|
77
|
+
// whose engine does not exist yet is drawn and says so (`coming`) rather than
|
|
78
|
+
// looking empty.
|
|
79
|
+
const FLOORS = Object.freeze([
|
|
80
|
+
['charter.founding', 'charter'], ['charter.purpose', 'charter'], ['charter.membership', 'charter'],
|
|
81
|
+
['charter.council', 'charter'], ['charter.rules', 'charter'], ['charter.money', 'charter'],
|
|
82
|
+
['charter.review', 'charter'], ['charter.release', 'charter'],
|
|
83
|
+
['rules.in_force', 'rules'],
|
|
84
|
+
['people.council', 'people'], ['people.testers', 'people'], ['people.newcomers', 'people'], ['people.ranks', 'people'],
|
|
85
|
+
['planning.goals', 'planning'], ['planning.ideas', 'planning'], ['planning.priorities', 'planning'], ['planning.versions', 'planning'],
|
|
86
|
+
['automation.allowed_goals', 'automation'], ['automation.running', 'automation'],
|
|
87
|
+
['automation.spending_limit', 'automation'], ['automation.reports', 'automation'],
|
|
88
|
+
['review.testers', 'review'], ['review.signed_off', 'review'],
|
|
89
|
+
['release.held_back', 'release'], ['release.live_month', 'release'],
|
|
90
|
+
['treasury.rewards', 'treasury'], ['treasury.grading', 'treasury'], ['treasury.hosting', 'treasury'],
|
|
91
|
+
].map(([key, building]) => Object.freeze({ key, building })));
|
|
92
|
+
|
|
74
93
|
module.exports = {
|
|
75
94
|
BUILDINGS,
|
|
76
95
|
BUILDING_KEYS,
|
|
@@ -79,4 +98,5 @@ module.exports = {
|
|
|
79
98
|
PASS_RULE_TO_RULE,
|
|
80
99
|
STATES,
|
|
81
100
|
READINGS,
|
|
101
|
+
FLOORS,
|
|
82
102
|
};
|