@bongos/core 1.21.3 → 1.21.5

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 (73) hide show
  1. package/.bongos-core.json +128 -68
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +35 -1
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +389 -518
  12. package/docs/copy-registry.json +880 -2143
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +17 -25
  15. package/docs/page-readings.json +1008 -1246
  16. package/migrations/core_272_rename_cn_criterion_slugs.sql +40 -0
  17. package/modules/autonomy/governor-docket.js +101 -0
  18. package/modules/autonomy/routes/autonomy.js +5 -0
  19. package/modules/discord/board-broadcast.js +3 -1
  20. package/modules/discord/craft-broadcast.js +107 -0
  21. package/modules/discord/routes/discord.js +10 -0
  22. package/modules/discord/ship-broadcast.js +30 -2
  23. package/modules/economy/reward.js +3 -0
  24. package/modules/government/board.js +20 -5
  25. package/modules/government/docket-vocab.js +82 -0
  26. package/modules/government/docket.js +458 -0
  27. package/modules/government/routes/government.js +34 -0
  28. package/modules/hall-ui/public/genesis-home.js +9 -10
  29. package/modules/ideas/governor-docket.js +93 -0
  30. package/modules/ideas/routes/blockers.js +5 -0
  31. package/modules/lifecycle/criterion-ref.js +11 -0
  32. package/modules/lifecycle/db-tasks.js +7 -0
  33. package/modules/lifecycle/governor-docket.js +192 -0
  34. package/modules/lifecycle/routes/done-when.js +6 -0
  35. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  36. package/modules/lifecycle/routes/lifecycle.js +3 -0
  37. package/modules/onboarding/routes/access-requests.js +68 -27
  38. package/modules/public-landing/public/assets/cosmos.css +6 -50
  39. package/modules/public-landing/public/projects.html +1170 -1659
  40. package/modules/public-landing/public/projects.probes.json +18 -18
  41. package/modules/public-landing/public/projects.states.json +23 -31
  42. package/modules/security/routes/reports.js +39 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +20 -0
  46. package/src/bongos/module-rechecks.js +77 -0
  47. package/src/bongos/route-rank-check.js +3 -0
  48. package/src/bongos/routes/core-update.js +41 -0
  49. package/src/bongos/routes/modules.js +17 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/criterion_goal_attach.mjs +12 -4
  52. package/tests/criterion_ref_parse.mjs +41 -0
  53. package/tests/discord_craft_broadcast.mjs +162 -0
  54. package/tests/genesis_home.mjs +1 -1
  55. package/tests/government_routes.mjs +3 -1
  56. package/tests/governor_docket.mjs +552 -0
  57. package/tests/onboard.mjs +10 -4
  58. package/tests/platform_boot.mjs +1 -1
  59. package/tests/projects_hub.mjs +196 -89
  60. package/tests/projects_hub_app_status.mjs +3 -1
  61. package/tests/projects_hub_app_step.mjs +72 -48
  62. package/tests/projects_hub_dns_ready.mjs +10 -2
  63. package/tests/projects_hub_look.mjs +32 -22
  64. package/tests/projects_hub_module_picker.mjs +112 -62
  65. package/tests/projects_hub_pre_uat.mjs +48 -41
  66. package/tests/public_landing_projects.mjs +12 -6
  67. package/tests/wizard_demo.mjs +37 -11
  68. package/tests/wizard_draft_resume.mjs +34 -22
  69. package/tests/wizard_front_door.mjs +173 -103
  70. package/tests/wizard_intent_resume.mjs +28 -25
  71. package/tests/wizard_physics.mjs +94 -76
  72. package/tests/wizard_physics_more.mjs +409 -277
  73. package/tests/wizard_six_screens.mjs +126 -0
@@ -0,0 +1,458 @@
1
+ 'use strict';
2
+
3
+ // modules/government/docket.js — the governor's docket: ONE read of every matter
4
+ // waiting on, or moving for, a governor, plus the six readings (task 1004523,
5
+ // goal 1000125, the Governor City).
6
+ //
7
+ // WHY. The Governor City (docs/design/mocks/governor-city/, Street.dc.html's
8
+ // matters() / status() / signsOf()) draws every part of a project's government
9
+ // as a building, with a pin over each open matter. Before this file the matters
10
+ // lived in a dozen places — the board, blockers, access requests, override
11
+ // requests, removal proposals, red-team reports, criteria awaiting UAT, the core
12
+ // update, CI gate approvals, the Autobongos fence, goal join requests — and the
13
+ // only cross-source summary was a browser card fetching five of them one by one.
14
+ // The screen tasks (1004524 home city, 1004525 street, 1004526 interiors, 1004527
15
+ // floors, 1004528 matter cards) all read THIS instead, so they agree on what is
16
+ // open, where it sits and whose move it is.
17
+ //
18
+ // HOW A SOURCE GETS HERE: THE `governor.docket` CONTRIBUTION POINT. A module may
19
+ // not require a sibling (ADR 0083), and the sources live in five modules and the
20
+ // core. So each owner contributes its own source, built on its OWN existing db
21
+ // read, with `api.contribute('governor.docket', { … })` from its route factory —
22
+ // and this file collects them. The contract (validated here, fail-closed):
23
+ //
24
+ // { source: 'blockers', // unique key (EXPECTED_SOURCES)
25
+ // building: 'planning', // one of docket-vocab BUILDING_KEYS
26
+ // gate: { permission: 'blocker.review' } // hidden unless the viewer holds it
27
+ // | { anyBuilder: true }, // the read scopes itself to the viewer
28
+ // read: async (ctx) => [matter, …] }
29
+ //
30
+ // { reading: 'flow', gate: …, read: async (ctx) => { figure, line, value, hot } }
31
+ //
32
+ // `ctx` is { builderId, rank, has(permission) } — `has` answers from the
33
+ // viewer's permission set resolved ONCE for this request, server-side and
34
+ // uncached (ADR 0016). A contribution with no recognisable gate is DROPPED, never
35
+ // run: an unscoped source would show every viewer everything.
36
+ //
37
+ // RANK-SCOPED SERVER-SIDE. The gate a source declares is the SAME atom its own
38
+ // list route requires, so the docket can never show a viewer a matter its owning
39
+ // route would refuse them. A gated-out source is reported as `not_yours` with no
40
+ // rows — the screen can say a building has matters you cannot see without
41
+ // leaking them.
42
+ //
43
+ // NOTHING IS INVENTED. A source the design names that has no data yet (grade
44
+ // appeals, votes on new standing rules, the treasury's spending matters) is
45
+ // reported `coming`; a source whose module is off is `off`; a read that throws or
46
+ // times out is `unavailable`. None of them ever contributes a sample row.
47
+
48
+ const api = require('../../src/module-api');
49
+ const vocab = require('./docket-vocab');
50
+ const board = require('./board');
51
+ const db = require('./db');
52
+ const { resolveBoardMembers } = require('./board-membership');
53
+ const resolver = require('./resolver');
54
+
55
+ const log = api.logger('government');
56
+
57
+ const CONTRIBUTION_POINT = 'governor.docket';
58
+
59
+ // Every source the reference design names, with the building it stands in. The
60
+ // docket reports a status for each, so a screen never has to guess whether an
61
+ // empty building is empty or unbuilt.
62
+ const EXPECTED_SOURCES = Object.freeze([
63
+ Object.freeze({ source: 'board', building: 'charter', label: 'board sittings' }),
64
+ Object.freeze({ source: 'blockers', building: 'planning', label: 'blockers' }),
65
+ Object.freeze({ source: 'access_requests', building: 'people', label: 'access requests' }),
66
+ Object.freeze({ source: 'goal_join_requests', building: 'people', label: 'goal join requests' }),
67
+ Object.freeze({ source: 'override_requests', building: 'release', label: 'override requests' }),
68
+ Object.freeze({ source: 'removal_proposals', building: 'disputes', label: 'removal proposals' }),
69
+ Object.freeze({ source: 'redteam_reports', building: 'disputes', label: 'red-team reports' }),
70
+ Object.freeze({ source: 'criteria_uat', building: 'review', label: 'criteria awaiting UAT' }),
71
+ Object.freeze({ source: 'core_update', building: 'release', label: 'core update' }),
72
+ Object.freeze({ source: 'gate_approvals', building: 'release', label: 'CI gate approvals' }),
73
+ Object.freeze({ source: 'autobongos_fence', building: 'automation', label: 'the Autobongos fence' }),
74
+ Object.freeze({ source: 'module_rechecks', building: 'release', label: 'module re-checks' }),
75
+ ]);
76
+
77
+ // Named by the design, not yet recorded anywhere. Each says why, in words.
78
+ const COMING_SOURCES = Object.freeze([
79
+ Object.freeze({ source: 'grade_appeals', building: 'disputes', label: 'grade appeals',
80
+ reason: 'There is no way to appeal a grade yet.' }),
81
+ Object.freeze({ source: 'standing_rules', building: 'rules', label: 'standing rules',
82
+ reason: 'A new standing rule has nowhere to be proposed yet. The one rule in force, idea routing, is counted in the rules-at-work reading.' }),
83
+ Object.freeze({ source: 'treasury_spending', building: 'treasury', label: 'spending matters',
84
+ reason: 'The project has no budget or spending votes recorded yet.' }),
85
+ ]);
86
+
87
+
88
+ // A source read that has not answered by now is reported unavailable rather than
89
+ // holding the whole city hostage (one source calls GitHub, one the npm registry).
90
+ const SOURCE_TIMEOUT_MS = 4000;
91
+
92
+ // ── pure helpers ──────────────────────────────────────────────────────────────
93
+
94
+ function iso(v) {
95
+ if (v == null || v === '') return null;
96
+ const d = v instanceof Date ? v : new Date(v);
97
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
98
+ }
99
+
100
+ function person(p) {
101
+ if (!p || typeof p !== 'object') return null;
102
+ const id = p.id == null ? null : String(p.id);
103
+ const login = p.login == null ? null : String(p.login);
104
+ const name = p.name == null ? null : String(p.name);
105
+ if (id == null && login == null && name == null) return null;
106
+ return { id, login, name };
107
+ }
108
+
109
+ function tally(t) {
110
+ if (!t || typeof t !== 'object') return null;
111
+ const n = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
112
+ return {
113
+ for: n(t.for),
114
+ against: n(t.against),
115
+ open: n(t.open),
116
+ needs: t.needs == null ? null : String(t.needs),
117
+ };
118
+ }
119
+
120
+ // One matter as a contributor handed it → the docket's one shape. Pure. Unknown
121
+ // rule → null (the card says so), unknown state → 'moving' (never a false
122
+ // "needs you"), unknown building → the source's own building.
123
+ function normalizeMatter(raw, { source, building }) {
124
+ if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
125
+ const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
126
+ return {
127
+ id: `${source}:${raw.id}`,
128
+ source,
129
+ building: b,
130
+ rule: vocab.RULE_KEYS.includes(raw.rule) ? raw.rule : null,
131
+ state: vocab.STATES.includes(raw.state) ? raw.state : 'moving',
132
+ title: String(raw.title),
133
+ short: raw.short ? String(raw.short) : null,
134
+ where: raw.where ? String(raw.where) : null,
135
+ opened_at: iso(raw.opened_at),
136
+ deadline: iso(raw.deadline),
137
+ proposer: person(raw.proposer),
138
+ tally: tally(raw.tally),
139
+ href: raw.href ? String(raw.href) : null,
140
+ ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
141
+ };
142
+ }
143
+
144
+ // Does this viewer pass this gate? Fail-closed: anything unrecognised is false.
145
+ function gateAllows(gate, ctx) {
146
+ if (!gate || typeof gate !== 'object') return false;
147
+ if (gate.anyBuilder === true) return true;
148
+ if (typeof gate.permission === 'string' && gate.permission) return ctx.has(gate.permission);
149
+ return false;
150
+ }
151
+
152
+ function hasValidGate(gate) {
153
+ return !!gate && typeof gate === 'object'
154
+ && (gate.anyBuilder === true || (typeof gate.permission === 'string' && !!gate.permission));
155
+ }
156
+
157
+ // needs first, then by deadline (soonest first, none last), then oldest first.
158
+ const STATE_ORDER = { needs: 0, moving: 1, settled: 2 };
159
+ function compareMatters(a, b) {
160
+ if (STATE_ORDER[a.state] !== STATE_ORDER[b.state]) return STATE_ORDER[a.state] - STATE_ORDER[b.state];
161
+ const da = a.deadline ? Date.parse(a.deadline) : Infinity;
162
+ const dbb = b.deadline ? Date.parse(b.deadline) : Infinity;
163
+ if (da !== dbb) return da - dbb;
164
+ const oa = a.opened_at ? Date.parse(a.opened_at) : Infinity;
165
+ const ob = b.opened_at ? Date.parse(b.opened_at) : Infinity;
166
+ return oa - ob;
167
+ }
168
+
169
+ function countStates(matters) {
170
+ const c = { needs: 0, moving: 0, settled: 0 };
171
+ for (const m of matters) c[m.state] += 1;
172
+ return c;
173
+ }
174
+
175
+ // The home line under "start governing", in the design's words.
176
+ function homeLine(counts) {
177
+ if (!counts.needs && !counts.moving) return 'nothing waits on you.';
178
+ const parts = [];
179
+ if (counts.needs) parts.push(`${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you`);
180
+ if (counts.moving) parts.push(`${counts.moving} moving`);
181
+ return parts.join(' · ');
182
+ }
183
+
184
+ // ── the board, read here (this module owns it) ───────────────────────────────
185
+
186
+ const PASS_RULE_NEEDS = Object.freeze({
187
+ first_ratifier: 'one yes decides',
188
+ consent: 'passes unless someone objects',
189
+ majority: 'needs more than half',
190
+ unanimous: 'needs everyone who votes',
191
+ });
192
+
193
+ // One open or recently closed sitting, for one viewer. Pure.
194
+ //
195
+ // "needs" is board.awaitsVoter, the SAME predicate pendingVotesFor (the /me
196
+ // summons) runs — open, not past its deadline, a member under the item's OWN
197
+ // snapshot, not already cast, not the subject's author (a genesis stage has no
198
+ // author to hold back) — so the city never promises a ballot the vote route then
199
+ // refuses, and never disagrees with the roster about it. One definition, held by
200
+ // tests/governor_docket.mjs driving both over the same matrix.
201
+ function boardMatter(item, { viewerId, members }) {
202
+ const viewer = String(viewerId);
203
+ const constitution = item.constitution || {};
204
+ const votes = item.votes || [];
205
+ const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
206
+ const voted = votes.some((v) => String(v.voter_id) === viewer);
207
+ const authorId = item.subject_type === 'genesis_stage'
208
+ ? null
209
+ : (item.subject && item.subject.author_id != null ? String(item.subject.author_id) : null);
210
+ const isOpen = !item.closed_at;
211
+ let state;
212
+ if (!isOpen) state = 'settled';
213
+ else if (board.awaitsVoter({ builderId: viewer, authorId, alreadyVoted: voted, isMember: memberIds.has(viewer), expired: item.expired === true })) state = 'needs';
214
+ else if (voted || memberIds.has(viewer) || authorId === viewer) state = 'moving';
215
+ else state = 'settled';
216
+
217
+ const building = item.subject_type === 'full_idea' ? 'planning' : 'charter';
218
+ const title = item.subject_type === 'constitutional_amendment'
219
+ ? (item.subject && item.subject.rationale_md ? firstLine(item.subject.rationale_md) : 'A change to the charter')
220
+ : item.subject_type === 'genesis_stage'
221
+ ? `Close the founding stage: ${(item.subject && item.subject.label) || item.subject_id}`
222
+ : ((item.subject && item.subject.title) || `Full idea ${item.subject_id}`);
223
+ const where = item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
224
+ : item.subject_type === 'genesis_stage' ? 'charter · founding'
225
+ : 'planning · idea to ratify';
226
+ const forN = votes.filter((v) => v.direction === 'yes').length;
227
+ const againstN = votes.filter((v) => v.direction === 'no').length;
228
+ const castIds = new Set(votes.map((v) => String(v.voter_id)));
229
+ const open = [...memberIds].filter((id) => !castIds.has(id)).length;
230
+ const proposerRow = authorId == null ? null
231
+ : (members || []).find((m) => String(m.builder_id) === authorId)
232
+ || votes.find((v) => String(v.voter_id) === authorId);
233
+ return {
234
+ id: item.id,
235
+ building,
236
+ rule: vocab.PASS_RULE_TO_RULE[constitution.pass_rule] || null,
237
+ state,
238
+ title,
239
+ short: item.subject_type === 'constitutional_amendment' ? 'amendment'
240
+ : item.subject_type === 'genesis_stage' ? 'founding stage' : 'idea to ratify',
241
+ where,
242
+ opened_at: item.opened_at,
243
+ deadline: item.closes_at,
244
+ proposer: authorId == null ? null : {
245
+ id: authorId,
246
+ login: proposerRow ? proposerRow.github_login : null,
247
+ name: proposerRow ? proposerRow.display_name : null,
248
+ },
249
+ tally: { for: forN, against: againstN, open: isOpen ? open : 0, needs: PASS_RULE_NEEDS[constitution.pass_rule] || null },
250
+ href: `/board-room?item=${encodeURIComponent(item.id)}`,
251
+ ref: { type: 'board_item', id: item.id },
252
+ };
253
+ }
254
+
255
+ function firstLine(md) {
256
+ const line = String(md).split(/\r?\n/).map((s) => s.replace(/^[#>*\-\s]+/, '').trim()).find(Boolean);
257
+ if (!line) return 'A change to the charter';
258
+ return line.length > 120 ? `${line.slice(0, 117)}…` : line;
259
+ }
260
+
261
+ // A closed sitting stays on the docket as settled for this long, so a pass is
262
+ // seen once before its pin drops (README: "Passed matters drop their pin").
263
+ const SETTLED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
264
+
265
+ // The board source: open sittings + sittings closed in the last week. Members are
266
+ // resolved once per DISTINCT predicate, as pendingVotesFor does.
267
+ async function readBoard(ctx, { now = Date.now() } = {}) {
268
+ const view = await board.boardRoomView({ closedLimit: 20 });
269
+ const recent = (view.closed || []).filter((i) => i.closed_at && now - Date.parse(i.closed_at) <= SETTLED_WINDOW_MS);
270
+ const items = [...(view.open || []), ...recent];
271
+ const membersBy = new Map();
272
+ const out = [];
273
+ for (const item of items) {
274
+ const key = String((item.constitution || {}).membership || '');
275
+ if (!membersBy.has(key)) membersBy.set(key, await resolveBoardMembers(item.constitution || {}).catch(() => []));
276
+ out.push(boardMatter(item, { viewerId: ctx.builderId, members: membersBy.get(key) }));
277
+ }
278
+ return out;
279
+ }
280
+
281
+ const BOARD_SOURCE = Object.freeze({
282
+ source: 'board',
283
+ building: 'charter',
284
+ gate: Object.freeze({ permission: 'board.vote.cast' }),
285
+ read: readBoard,
286
+ });
287
+
288
+ // ── readings this module can answer ──────────────────────────────────────────
289
+
290
+ const OWN_READINGS = Object.freeze([
291
+ Object.freeze({
292
+ reading: 'people',
293
+ gate: Object.freeze({ anyBuilder: true }),
294
+ read: async () => {
295
+ const n = await db.countActiveBuilders();
296
+ return { value: n, figure: `${n} ${n === 1 ? 'builder' : 'builders'}`, line: 'active people in the project', hot: false };
297
+ },
298
+ }),
299
+ ]);
300
+
301
+ // Open disputes, from the matters the viewer can see at Disputes. Pure.
302
+ function fairnessReading(matters, sourceStatus) {
303
+ const seen = sourceStatus.filter((s) => s.building === 'disputes' && s.status === 'live');
304
+ if (!seen.length) return null;
305
+ const open = matters.filter((m) => m.building === 'disputes' && m.state !== 'settled');
306
+ const n = open.length;
307
+ const oldest = open.map((m) => m.opened_at).filter(Boolean).sort()[0] || null;
308
+ return {
309
+ value: n,
310
+ figure: `${n} open`,
311
+ line: n ? `${n === 1 ? 'one dispute' : `${n} disputes`} waiting · oldest since ${oldest ? oldest.slice(0, 10) : 'unknown'}` : 'no open disputes',
312
+ hot: open.some((m) => m.state === 'needs'),
313
+ };
314
+ }
315
+
316
+ function withTimeout(promise, ms) {
317
+ let timer;
318
+ return Promise.race([
319
+ promise,
320
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); if (timer.unref) timer.unref(); }),
321
+ ]).finally(() => clearTimeout(timer));
322
+ }
323
+
324
+ // The registered contributions, last registration per key winning (a route
325
+ // factory re-runs whenever a test rebuilds the router; the newest closure is the
326
+ // live one).
327
+ function registeredContributions() {
328
+ const sources = new Map();
329
+ const readings = new Map();
330
+ for (const c of api.contributions(CONTRIBUTION_POINT)) {
331
+ if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
332
+ if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
333
+ else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
334
+ }
335
+ return { sources, readings };
336
+ }
337
+
338
+ // ── the collector ────────────────────────────────────────────────────────────
339
+
340
+ // Build the docket for one viewer. `permissions` is the viewer's resolved set;
341
+ // `contributions` defaults to what is registered on the seam (a test passes its
342
+ // own). Never throws for a source: each is isolated and timed.
343
+ async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS } = {}) {
344
+ const held = new Set(permissions || []);
345
+ const ctx = Object.freeze({
346
+ builderId: String(viewer.id),
347
+ rank: viewer.rank || null,
348
+ has: (p) => held.has(p),
349
+ });
350
+ const sources = new Map(contributions.sources);
351
+ sources.set('board', BOARD_SOURCE); // the board is read here, never contributed
352
+ const comingKeys = new Set(COMING_SOURCES.map((c) => c.source));
353
+
354
+ const matters = [];
355
+ const sourceStatus = [];
356
+ const declared = [...EXPECTED_SOURCES, ...COMING_SOURCES.filter((c) => !EXPECTED_SOURCES.some((e) => e.source === c.source))];
357
+ await Promise.all(declared.map(async (decl) => {
358
+ const c = sources.get(decl.source);
359
+ const row = { source: decl.source, building: decl.building, label: decl.label, status: null, count: 0, reason: null };
360
+ sourceStatus.push(row);
361
+ if (!c) {
362
+ if (comingKeys.has(decl.source)) {
363
+ row.status = 'coming';
364
+ row.reason = COMING_SOURCES.find((x) => x.source === decl.source).reason;
365
+ } else {
366
+ row.status = 'off';
367
+ row.reason = 'The module that holds this is not enabled here.';
368
+ }
369
+ return;
370
+ }
371
+ if (!gateAllows(c.gate, ctx)) { row.status = 'not_yours'; return; }
372
+ try {
373
+ const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
374
+ const list = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: decl.source, building: c.building || decl.building })).filter(Boolean);
375
+ row.status = 'live';
376
+ row.count = list.length;
377
+ matters.push(...list);
378
+ } catch (err) {
379
+ row.status = 'unavailable';
380
+ row.reason = err && err.message === 'timeout' ? 'It did not answer in time.' : 'It could not be read just now.';
381
+ log.warn({ source: decl.source, err: err && err.message }, 'governor docket source failed');
382
+ }
383
+ }));
384
+ // Stable order: the declared order, not the order the reads finished in.
385
+ const order = declared.map((d) => d.source);
386
+ sourceStatus.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source));
387
+ matters.sort(compareMatters);
388
+
389
+ // Readings: contributed + this module's own + the ones derived from matters.
390
+ const readingImpls = new Map(contributions.readings);
391
+ for (const r of OWN_READINGS) readingImpls.set(r.reading, r);
392
+ const readings = await Promise.all(vocab.READINGS.map(async (decl) => {
393
+ const base = { key: decl.key, label: decl.label, building: decl.building, status: null, value: null, figure: null, line: null, hot: false, reason: null };
394
+ if (decl.key === 'fairness') {
395
+ const f = fairnessReading(matters, sourceStatus);
396
+ return f ? { ...base, status: 'live', ...f } : { ...base, status: 'not_yours' };
397
+ }
398
+ const impl = readingImpls.get(decl.key);
399
+ if (!impl) return { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
400
+ if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
401
+ try {
402
+ const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters })), timeoutMs);
403
+ if (!r) return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
404
+ return {
405
+ ...base,
406
+ status: 'live',
407
+ value: r.value ?? null,
408
+ figure: r.figure == null ? null : String(r.figure),
409
+ line: r.line == null ? null : String(r.line),
410
+ hot: r.hot === true,
411
+ };
412
+ } catch (err) {
413
+ log.warn({ reading: decl.key, err: err && err.message }, 'governor docket reading failed');
414
+ return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
415
+ }
416
+ }));
417
+
418
+ const counts = countStates(matters);
419
+ const buildings = vocab.BUILDINGS.map((b) => {
420
+ const here = matters.filter((m) => m.building === b.key);
421
+ return { key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id) };
422
+ });
423
+ return {
424
+ viewer: { id: ctx.builderId },
425
+ generated_at: new Date(now).toISOString(),
426
+ counts,
427
+ line: homeLine(counts),
428
+ matters,
429
+ buildings,
430
+ sources: sourceStatus,
431
+ readings,
432
+ vocabulary: { buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES },
433
+ };
434
+ }
435
+
436
+ // The route's entry point: resolve the viewer's permissions server-side (fresh,
437
+ // uncached — ADR 0016), then build.
438
+ async function docketFor(builder) {
439
+ const permissions = await resolver.resolveBuilderPermissions(builder.id);
440
+ return buildDocket({ viewer: builder, permissions });
441
+ }
442
+
443
+ module.exports = {
444
+ CONTRIBUTION_POINT,
445
+ EXPECTED_SOURCES,
446
+ COMING_SOURCES,
447
+ SOURCE_TIMEOUT_MS,
448
+ SETTLED_WINDOW_MS,
449
+ normalizeMatter,
450
+ gateAllows,
451
+ compareMatters,
452
+ homeLine,
453
+ boardMatter,
454
+ fairnessReading,
455
+ registeredContributions,
456
+ buildDocket,
457
+ docketFor,
458
+ };
@@ -26,6 +26,7 @@ const sessionScopes = require('../session-scopes');
26
26
  const boardMembership = require('../board-membership');
27
27
  const board = require('../board');
28
28
  const recommendations = require('../recommendations');
29
+ const docket = require('../docket');
29
30
  // task 1003208: structured logging (pino via the doorway) — was console.*.
30
31
  const log = api.logger('government');
31
32
 
@@ -531,6 +532,39 @@ module.exports = function buildGovernmentRouter() {
531
532
  }
532
533
  });
533
534
 
535
+ // rank: perm board.vote.cast — the governor's DOCKET (task 1004523, goal
536
+ // 1000125): ONE read of every matter waiting on, or moving for, a governor,
537
+ // which the Governor City screens all draw from. Same coarse atom as the room
538
+ // (xenos floor: whoever the constitution may enfranchise may open the city);
539
+ // the real scoping is PER SOURCE, inside docket.js — each source is shown only
540
+ // to a viewer holding the same atom its own list route requires, and a hidden
541
+ // source reports `not_yours` with no rows (ADR 0016, server-side, uncached).
542
+ //
543
+ // Response (docket.buildDocket):
544
+ // { viewer: { id }, generated_at, line, // "3 matters wait on you · 2 moving"
545
+ // counts: { needs, moving, settled },
546
+ // matters: [{ id: '<source>:<id>', source, building, rule, state,
547
+ // title, short, where, opened_at, deadline,
548
+ // proposer: { id, login, name } | null,
549
+ // tally: { for, against, open, needs } | null, href, ref }],
550
+ // buildings: [{ key, name, group, counts, matter_ids }], // street order
551
+ // sources: [{ source, building, label, status, count, reason }],
552
+ // // status: live | not_yours | coming | off | unavailable
553
+ // readings: [{ key, label, building, status, value, figure, line, hot, reason }],
554
+ // vocabulary: { buildings, rules, states } }
555
+ // `building` ∈ charter rules disputes people planning automation review release
556
+ // treasury; `rule` ∈ decide first consent majority unanimous signoff rule (or
557
+ // null); `state` ∈ needs moving settled. Nothing is ever sample data: a source
558
+ // with nothing recorded yet is `coming`, never a placeholder row.
559
+ router.get('/government/docket', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
560
+ try {
561
+ res.json(await docket.docketFor(req.builder));
562
+ } catch (err) {
563
+ log.error('[gds] GET /government/docket', err);
564
+ res.fail('docket_read_failed', { status: 500, message: 'internal error' });
565
+ }
566
+ });
567
+
534
568
  // rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
535
569
  // 1000069, ADR 0175 §7). The board's other subject never comes through here:
536
570
  // a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
@@ -97,8 +97,8 @@
97
97
  + '<label class="genesis__plan-len"><span class="sr-only">Length of ' + esc(s.label) + '</span>'
98
98
  + '<select data-days="' + esc(s.key) + '">' + opts + '</select></label></li>';
99
99
  }).join('');
100
- return '<p class="scroll__lede">Settle the plan, then genesis files each stage as real tasks on the board. '
101
- + 'Every choice below is already set to its recommended default, so on your own one click is enough.</p>'
100
+ return '<p class="scroll__lede">Settle the plan and each stage becomes real tasks. '
101
+ + 'Everything is preset to our recommendation, so one click is enough.</p>'
102
102
  + '<ol class="genesis__plan">' + rows + '</ol>'
103
103
  + '<div class="genesis__picks">'
104
104
  + governmentHtml(gov)
@@ -108,9 +108,9 @@
108
108
  + '</fieldset>'
109
109
  + '<fieldset class="genesis__pick"><legend>Advisors</legend>'
110
110
  + radio('advisors', 'none', 'None for now', 'You can add advisors at any stage.', true)
111
- + radio('advisors', 'cbaas', 'CBaaS advisors', 'Ask for Cloud Bongos consultants to review each stage.', false)
111
+ + radio('advisors', 'cbaas', 'Cloud Bongos advisors', 'Our consultants review each stage.', false)
112
112
  + '</fieldset>'
113
- + '<label class="genesis__check"><input type="checkbox" data-existing> This project already exists, so start by gathering what it has</label>'
113
+ + '<label class="genesis__check"><input type="checkbox" data-existing> It already exists, so gather what it has first</label>'
114
114
  + '</div>'
115
115
  + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-plan>Use this plan</button></div>';
116
116
  }
@@ -208,7 +208,7 @@
208
208
  if (g.can_close) {
209
209
  return '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-close="' + esc(s.key) + '">Close this stage</button></div>';
210
210
  }
211
- return '<p class="genesis__note">When every task above is finished, this stage can be put to the board.</p>';
211
+ return '<p class="genesis__note">Finish every task above, then put this stage to the board.</p>';
212
212
  }
213
213
 
214
214
  function aliveHtml() {
@@ -217,8 +217,7 @@
217
217
  + '<div class="genesis__alive-plates">' + ['r5-life-1-primordial-sea', 'r5-life-2-first-cells', 'r5-life-3-genome', 'r5-life-4-membrane', 'r5-life-5-nervous-system']
218
218
  .map(function (p) { return plateHtml(p, 'gplate--sm'); }).join('') + '</div>'
219
219
  + '<h2 id="genesis-h" class="genesis__alive-h">' + esc(name) + ' is alive</h2>'
220
- + '<p class="scroll__lede">Genesis is complete. Every room is open now: Community, the Builders list and the rest of the hall. '
221
- + 'The founding builders keep their mark for good.</p>'
220
+ + '<p class="scroll__lede">Every room in the hall is open now. The founding builders keep their mark for good.</p>'
222
221
  + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-enter>Enter the builders’ hall</button></div></div>';
223
222
  }
224
223
 
@@ -234,9 +233,9 @@
234
233
  var d = g.demo;
235
234
  if (!d) return '';
236
235
  var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
237
- return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
238
- + 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
239
- + esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
236
+ return '<p class="scroll__lede">Your demo’s genesis takes about ' + esc(String(total)) + ' min. '
237
+ + 'The plan is set: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
238
+ + esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it.</p>';
240
239
  }
241
240
 
242
241
  function render(host, state) {
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/governor-docket.js — the ideas module's part of the governor's
4
+ // docket (task 1004523, goal 1000125; collector: modules/government/docket.js,
5
+ // decision: ADR 0362).
6
+ //
7
+ // WHY. Two things the Governor City shows live here. Open BLOCKERS stand at
8
+ // Planning ("waiting on a decision"). And the "rules at work" reading — matters
9
+ // settled by a rule, without a vote — is the idea ROUTING rule (routing.js, the
10
+ // landing matrix): a filing that has a home lands in its goal at filing time
11
+ // instead of waiting for a triage vote, and each one leaves an
12
+ // `ideas_task_lineage` row with origin 'route'. That count is the honest answer
13
+ // to "what did the rules settle this month"; the design's other standing rules
14
+ // do not exist yet and are not counted.
15
+ //
16
+ // A module may not require a sibling (ADR 0083), so this is contributed to the
17
+ // `governor.docket` point from the blockers route factory rather than reached
18
+ // for by government. Pure mappers exported for tests/governor_docket.mjs.
19
+
20
+ const api = require('../../src/module-api');
21
+ const blockers = require('./blockers');
22
+
23
+ const POINT = 'governor.docket';
24
+ const DOCKET_BLOCKER_LIMIT = 50;
25
+
26
+ // One open blocker for one viewer. The list is public (GET /blockers), so every
27
+ // governor sees it; only a viewer who may resolve it (`blocker.review`, the
28
+ // resolve route's atom) is told it needs them — for anyone else it is moving.
29
+ function blockerDocketMatter(b, mayResolve) {
30
+ const holds = Array.isArray(b.blocking_task_ids) ? b.blocking_task_ids.length : 0;
31
+ return {
32
+ id: b.id,
33
+ rule: 'decide',
34
+ state: mayResolve ? 'needs' : 'moving',
35
+ title: b.title,
36
+ short: 'blocker',
37
+ where: holds ? `planning · holds ${holds} ${holds === 1 ? 'task' : 'tasks'}` : 'planning · waiting on a decision',
38
+ opened_at: b.created_at,
39
+ deadline: null,
40
+ proposer: null,
41
+ tally: null,
42
+ href: `/blockers#blocker-${b.id}`,
43
+ ref: { type: 'blocker', id: b.id },
44
+ };
45
+ }
46
+
47
+ // The rules-at-work reading from this month's routed filings. Pure.
48
+ function rulesReading(settledThisMonth) {
49
+ const n = Number(settledThisMonth) || 0;
50
+ return {
51
+ value: { rules: 1, settled_this_month: n },
52
+ figure: '1 rule',
53
+ line: n
54
+ ? `the landing rule settled ${n} ${n === 1 ? 'filing' : 'filings'} this month without a vote`
55
+ : 'the landing rule settled nothing this month',
56
+ hot: false,
57
+ };
58
+ }
59
+
60
+ // Filings the routing rule landed this month (origin 'route').
61
+ async function countRoutedThisMonth() {
62
+ const { rows } = await api.pool.query(
63
+ `SELECT count(*)::int AS n FROM ideas_task_lineage
64
+ WHERE origin = 'route' AND created_at >= date_trunc('month', now())`,
65
+ );
66
+ return rows[0] ? rows[0].n : 0;
67
+ }
68
+
69
+ function contributeIdeasToDocket() {
70
+ api.contribute(POINT, {
71
+ source: 'blockers',
72
+ building: 'planning',
73
+ gate: { anyBuilder: true },
74
+ read: async (ctx) => {
75
+ const rows = await blockers.listOpenBlockers({ limit: DOCKET_BLOCKER_LIMIT, offset: 0 });
76
+ const mayResolve = ctx.has('blocker.review');
77
+ return rows.map((b) => blockerDocketMatter(b, mayResolve));
78
+ },
79
+ });
80
+ // A count, no rows: any builder may read it.
81
+ api.contribute(POINT, {
82
+ reading: 'rules',
83
+ gate: { anyBuilder: true },
84
+ read: async () => rulesReading(await countRoutedThisMonth()),
85
+ });
86
+ }
87
+
88
+ module.exports = {
89
+ contributeIdeasToDocket,
90
+ blockerDocketMatter,
91
+ rulesReading,
92
+ countRoutedThisMonth,
93
+ };
@@ -27,7 +27,12 @@ const api = require('../../../src/module-api');
27
27
  const blockers = require('../blockers');
28
28
  const { validateOrRespond, LIMITS, parseId } = api;
29
29
 
30
+ // The governor's docket (task 1004523): open blockers + the rules-at-work
31
+ // reading, contributed from this factory (see ../governor-docket.js).
32
+ const governorDocket = require('../governor-docket');
33
+
30
34
  module.exports = function buildBlockersRouter() {
35
+ governorDocket.contributeIdeasToDocket();
31
36
  const router = express.Router();
32
37
 
33
38
  // List currently-open blockers + the tasks each is blocking.