@bongos/core 1.21.19 → 1.21.21

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.
Files changed (70) hide show
  1. package/.bongos-core.json +84 -74
  2. package/docs/copy-inventory.md +29 -25
  3. package/docs/copy-registry.json +87 -30
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +9 -3
  6. package/docs/page-readings.json +399 -388
  7. package/modules/autonomy/governor-docket.js +118 -4
  8. package/modules/economy/cost.js +36 -0
  9. package/modules/economy/governor-docket.js +81 -0
  10. package/modules/economy/routes/cost.js +2 -0
  11. package/modules/government/docket-floors.js +222 -0
  12. package/modules/government/docket-vocab.js +20 -0
  13. package/modules/government/docket.js +186 -5
  14. package/modules/hall-ui/public/atlas.html +1 -1
  15. package/modules/hall-ui/public/blockers.html +1 -1
  16. package/modules/hall-ui/public/board-room.html +1 -1
  17. package/modules/hall-ui/public/city.css +16 -2
  18. package/modules/hall-ui/public/city.html +29 -9
  19. package/modules/hall-ui/public/city.js +25 -2
  20. package/modules/hall-ui/public/city.states.json +7 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/commands.html +1 -1
  23. package/modules/hall-ui/public/copy-desk.html +1 -1
  24. package/modules/hall-ui/public/deploy.html +1 -1
  25. package/modules/hall-ui/public/diagrams.html +1 -1
  26. package/modules/hall-ui/public/drachmae.html +1 -1
  27. package/modules/hall-ui/public/fleet.html +1 -1
  28. package/modules/hall-ui/public/gate.html +1 -1
  29. package/modules/hall-ui/public/goal-map.html +1 -1
  30. package/modules/hall-ui/public/goals.html +1 -1
  31. package/modules/hall-ui/public/government.html +1 -1
  32. package/modules/hall-ui/public/idea.html +1 -1
  33. package/modules/hall-ui/public/ideas.html +1 -1
  34. package/modules/hall-ui/public/index.html +1 -1
  35. package/modules/hall-ui/public/modules.html +1 -1
  36. package/modules/hall-ui/public/primer.html +1 -1
  37. package/modules/hall-ui/public/profile.html +1 -1
  38. package/modules/hall-ui/public/project-settings.html +1 -1
  39. package/modules/hall-ui/public/ranks.html +1 -1
  40. package/modules/hall-ui/public/roadmap.html +1 -1
  41. package/modules/hall-ui/public/roster.html +1 -1
  42. package/modules/hall-ui/public/sessions.html +1 -1
  43. package/modules/hall-ui/public/settings.html +1 -1
  44. package/modules/hall-ui/public/shell.js +3 -0
  45. package/modules/hall-ui/public/street-inside.js +99 -43
  46. package/modules/hall-ui/public/street.css +17 -4
  47. package/modules/hall-ui/public/street.html +26 -8
  48. package/modules/hall-ui/public/street.js +16 -2
  49. package/modules/hall-ui/public/street.states.json +17 -1
  50. package/modules/hall-ui/public/studio.html +1 -1
  51. package/modules/hall-ui/public/task.html +1 -1
  52. package/modules/hall-ui/public/thinking.html +1 -1
  53. package/modules/hall-ui/public/tweak-editor.html +1 -1
  54. package/modules/hall-ui/public/watch.html +1 -1
  55. package/modules/hall-ui/public/work.html +1 -1
  56. package/modules/ideas/governor-docket.js +42 -1
  57. package/modules/lifecycle/governor-docket.js +197 -2
  58. package/modules/security/routes/reports.js +16 -0
  59. package/modules/security/security.js +5 -2
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +12 -0
  63. package/src/module-api.js +1 -1
  64. package/tests/governor_docket.mjs +186 -2
  65. package/tests/hall_audit.mjs +7 -12
  66. package/tests/hall_city.mjs +44 -4
  67. package/tests/hall_street.mjs +37 -5
  68. package/tests/hall_street_inside.mjs +97 -3
  69. package/tests/hall_tweak_editor.mjs +1 -1
  70. package/tests/nav_permission_atoms.mjs +1 -1
@@ -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 db.readFence();
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.autonomousApiSpendMtd() });
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
  };
@@ -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
  };