@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.
- package/.bongos-core.json +128 -68
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +35 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +389 -518
- package/docs/copy-registry.json +880 -2143
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +17 -25
- package/docs/page-readings.json +1008 -1246
- package/migrations/core_272_rename_cn_criterion_slugs.sql +40 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +5 -0
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/criterion-ref.js +11 -0
- package/modules/lifecycle/db-tasks.js +7 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/done-when.js +6 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- package/modules/public-landing/public/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +23 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +41 -0
- package/src/bongos/routes/modules.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/criterion_goal_attach.mjs +12 -4
- package/tests/criterion_ref_parse.mjs +41 -0
- package/tests/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/wizard_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +409 -277
- 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
|
|
101
|
-
+ '
|
|
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', '
|
|
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>
|
|
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">
|
|
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">
|
|
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">
|
|
238
|
-
+ 'The plan is
|
|
239
|
-
+ esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it
|
|
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.
|