@bongos/core 1.21.35 → 1.21.36

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 (36) hide show
  1. package/.bongos-core.json +54 -39
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/api/openapi.json +25 -35
  4. package/docs/api-reference.md +1 -1
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +4 -1
  7. package/docs/page-readings.json +350 -304
  8. package/modules/autonomy/matter-acts.js +41 -0
  9. package/modules/autonomy/routes/autonomy.js +2 -0
  10. package/modules/government/board.js +4 -0
  11. package/modules/government/db.js +14 -0
  12. package/modules/government/decision-board.js +90 -11
  13. package/modules/government/docket-vocab.js +5 -0
  14. package/modules/government/docket.js +11 -0
  15. package/modules/government/routes/government.js +2 -1
  16. package/modules/hall-ui/public/street-card.js +62 -2
  17. package/modules/hall-ui/public/street-desk.js +101 -8
  18. package/modules/hall-ui/public/street-inside.js +6 -3
  19. package/modules/hall-ui/public/street.css +15 -0
  20. package/modules/hall-ui/public/street.js +73 -4
  21. package/modules/hall-ui/public/street.states.json +124 -0
  22. package/modules/ideas/governor-docket.js +91 -0
  23. package/modules/lifecycle/governor-docket.js +69 -0
  24. package/modules/lifecycle/matter-acts.js +63 -0
  25. package/modules/lifecycle/routes/done-when.js +26 -12
  26. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  27. package/modules/lifecycle/routes/lifecycle.js +2 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +10 -0
  31. package/src/bongos/route-rank-check.js +2 -0
  32. package/src/bongos/routes/builders.js +56 -1
  33. package/src/module-api.js +1 -1
  34. package/tests/governor_matter_acts.mjs +241 -0
  35. package/tests/hall_street_desk.mjs +244 -4
  36. package/tests/hall_street_inside.mjs +3 -3
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/matter-acts.js — what a PASSED Automation matter carries out
4
+ // (task 1004561, goal 1000125). When a project's decision table puts Automation
5
+ // to a sitting, the sitting's passed matter acts here, after the close commits:
6
+ // the switch on or off, or one goal allowed. Each act goes through the SAME db
7
+ // function its direct route calls (POST /autonomy/fence, POST
8
+ // /autonomy/fence/goals), so a matter can do nothing the route could not.
9
+ // The government module only checks the act's shape and calls in through the
10
+ // 'governor.matterAct' contribution point (a module never requires a sibling).
11
+
12
+ const api = require('../../src/module-api');
13
+ const db = require('./db');
14
+
15
+ const log = api.logger('autonomy');
16
+ const POINT = 'governor.matterAct';
17
+
18
+ const why = (ctx) => `a passed Automation matter (matter ${ctx && ctx.matterId})`;
19
+
20
+ async function switchFence(enabled, ctx) {
21
+ const row = await db.setFenceEnabled({ enabled, reason: why(ctx), builderId: ctx.decidedBy });
22
+ if (!row) return { done: false, reason: 'fence_uninitialised' };
23
+ log.info(`[gds] autonomy fence ${enabled ? 'ENABLED' : 'DISABLED'} by ${why(ctx)}, proposed by builder ${ctx.decidedBy}`);
24
+ return { done: true };
25
+ }
26
+
27
+ const ACTS = Object.freeze({
28
+ switch_on: (_action, ctx) => switchFence(true, ctx),
29
+ switch_off: (_action, ctx) => switchFence(false, ctx),
30
+ allow_goal: async (action, ctx) => {
31
+ await db.allowGoal({ goalId: action.goal_id, note: why(ctx), builderId: ctx.decidedBy });
32
+ log.info(`[gds] autonomy fence: goal ${action.goal_id} ALLOWED by ${why(ctx)}`);
33
+ return { done: true };
34
+ },
35
+ });
36
+
37
+ function contributeAutomationMatterActs() {
38
+ for (const [type, act] of Object.entries(ACTS)) api.contribute(POINT, { area: 'automation', type, act });
39
+ }
40
+
41
+ module.exports = { ACTS, contributeAutomationMatterActs };
@@ -108,6 +108,8 @@ const governorDocket = require('../governor-docket');
108
108
 
109
109
  module.exports = function buildAutonomyRouter() {
110
110
  governorDocket.contributeAutonomyToDocket();
111
+ // what a passed Automation matter carries out (task 1004561)
112
+ require('../matter-acts').contributeAutomationMatterActs();
111
113
  const router = express.Router();
112
114
 
113
115
  // The one Discord line for a runner idle on a setting (task 1004539). The port
@@ -1370,6 +1370,9 @@ async function boardPassForIdea(ideaId) {
1370
1370
  });
1371
1371
  }
1372
1372
 
1373
+ // boardPassForIdea for a list, in one read (task 1004560).
1374
+ const boardPassedIdeaIds = async (ids) => new Set(await db.listPassedSubjectIds('full_idea', ids));
1375
+
1373
1376
  // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
1374
1377
  //
1375
1378
  // THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
@@ -1485,6 +1488,7 @@ module.exports = {
1485
1488
  maybeCloseAfterVote,
1486
1489
  latestRejectionForIdea,
1487
1490
  boardPassForIdea,
1491
+ boardPassedIdeaIds,
1488
1492
  validateProposedConstitution,
1489
1493
  proposeAmendment,
1490
1494
  boardRoomView,
@@ -712,6 +712,19 @@ async function getLatestPassedItem(subjectType, subjectId) {
712
712
  return rows[0] || null;
713
713
  }
714
714
 
715
+ // Which of these subjects the board passed, in ONE read (task 1004560: the
716
+ // Ideas-to-ratify desk asks for every open Full Idea at once).
717
+ async function listPassedSubjectIds(subjectType, subjectIds) {
718
+ const ids = (subjectIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
719
+ if (!ids.length) return [];
720
+ const { rows } = await pool.query(
721
+ `SELECT DISTINCT subject_id FROM government_board_items
722
+ WHERE subject_type = $1 AND subject_id = ANY($2::bigint[]) AND outcome = 'passed'`,
723
+ [subjectType, ids],
724
+ );
725
+ return rows.map((r) => String(r.subject_id));
726
+ }
727
+
715
728
  // The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
716
729
  // the database clock, oldest deadline first. Served by the partial index
717
730
  // government_board_items_open_by_deadline (government_003 built it for exactly
@@ -872,6 +885,7 @@ module.exports = {
872
885
  getExpiredOpenBoardItems,
873
886
  getLatestReturnedItemWithObjection,
874
887
  getLatestPassedItem,
888
+ listPassedSubjectIds,
875
889
  createAmendment,
876
890
  getAmendment,
877
891
  listDecisionRules,
@@ -50,7 +50,9 @@ async function applyPassedDecisionRules(client, closedRow, amendment) {
50
50
  }
51
51
 
52
52
  // A passed matter: act where the building can act by itself, and build the
53
- // announcement. Never throws — the decision already stands.
53
+ // announcement. Never throws — the decision already stands. Runs after the
54
+ // close commits (People since task 1004529; Automation, Release, Review and
55
+ // Treasury since task 1004561, through their owning modules' acts).
54
56
  async function actOnPassedMatter(closed) {
55
57
  const area = decisionRules.areaForSubjectType(closed.subject_type);
56
58
  let matter = null;
@@ -71,11 +73,30 @@ async function actOnPassedMatter(closed) {
71
73
  await db.unassignRank({ builderId: action.builder_id, rankKey: action.rank_key });
72
74
  result = { ...result, action: action.type, done: true, builder_id: String(action.builder_id), rank_key: action.rank_key };
73
75
  }
76
+ } else if (area !== 'people' && action && MATTER_ACTIONS[area]) {
77
+ // The owning module carries it out, through its direct route's own
78
+ // function (task 1004561). Re-validated first: the stored row was checked
79
+ // when opened and is immutable since, so a refusal means it was altered.
80
+ const v = validateMatterAction(area, action);
81
+ const doer = v.ok ? matterActFor(area, v.action.type) : null;
82
+ if (!v.ok) {
83
+ result = { ...result, action: action.type || null, done: false, reason: 'bad_action' };
84
+ } else if (!doer) {
85
+ result = { ...result, action: v.action.type, done: false, reason: 'not_wired' };
86
+ } else {
87
+ const out = (await doer.act(v.action, { decidedBy: matter.proposed_by, matterId: String(matter.id) })) || {};
88
+ result = { ...result, action: v.action.type, done: out.done === true, ...(out.reason ? { reason: String(out.reason) } : {}) };
89
+ }
74
90
  }
75
91
  } catch (err) {
76
92
  log.error(`[government] matter ${closed.subject_id} passed at ${area} but its act did NOT run (re-drivable from the passed item): ${err && err.message}`);
77
93
  result = { ...result, done: false, reason: 'act_failed' };
78
94
  }
95
+ // A refusal (already decided elsewhere, the module off) leaves the decision
96
+ // standing; it is said loudly here and carried as `acted: false`.
97
+ if (result.action && !result.done && result.reason !== 'act_failed') {
98
+ log.warn(`[government] matter ${closed.subject_id} passed at ${area} but its act was refused: ${result.reason || 'not done'}`);
99
+ }
79
100
  return {
80
101
  result,
81
102
  payload: {
@@ -153,22 +174,74 @@ async function decisionRulesView() {
153
174
  // the whole point — People decided by a council's consent sits as exactly that.
154
175
  const MATTER_TITLE_MAX = 200;
155
176
  const MATTER_DETAIL_MAX = 5000;
156
- const MATTER_ACTION_TYPES = Object.freeze(['assign_rank', 'unassign_rank']);
177
+ // What a passed matter may carry out, per building (task 1004561). People acts
178
+ // here, in this module; every other act is carried out by the module that owns
179
+ // the building's direct route, through the same function that route calls,
180
+ // contributed to 'governor.matterAct' as { area, type, act }. This table is only
181
+ // the SHAPE of each act (field → kind), checked when the matter is opened and
182
+ // again before it acts. 'id' is a positive integer, 'rank' a custom rank key,
183
+ // 'usd' a non-negative amount or null (no limit).
184
+ const MATTER_ACTIONS = Object.freeze({
185
+ people: Object.freeze({
186
+ assign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
187
+ unassign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
188
+ }),
189
+ automation: Object.freeze({
190
+ switch_on: Object.freeze({}),
191
+ switch_off: Object.freeze({}),
192
+ allow_goal: Object.freeze({ goal_id: 'id' }),
193
+ }),
194
+ release: Object.freeze({
195
+ approve_override: Object.freeze({ request_id: 'id' }),
196
+ approve_gate: Object.freeze({ pr: 'id' }),
197
+ }),
198
+ review: Object.freeze({
199
+ satisfy_criterion: Object.freeze({ criterion_id: 'id' }),
200
+ }),
201
+ treasury: Object.freeze({
202
+ set_budget: Object.freeze({ builder_id: 'id', monthly_usd: 'usd' }),
203
+ }),
204
+ });
205
+ const MATTER_ACT_POINT = 'governor.matterAct';
206
+ const MAX_BUDGET_USD = 100000;
207
+
208
+ function fieldValue(kind, raw) {
209
+ if (kind === 'id') {
210
+ const n = Number(raw);
211
+ return Number.isSafeInteger(n) && n > 0 && raw !== null && raw !== '' && typeof raw !== 'boolean' ? { ok: true, v: n } : { ok: false };
212
+ }
213
+ if (kind === 'rank') return typeof raw === 'string' && /^[a-z][a-z0-9-]*$/.test(raw) && raw.length <= 64 ? { ok: true, v: raw } : { ok: false };
214
+ if (kind === 'usd') {
215
+ if (raw === null) return { ok: true, v: null };
216
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= MAX_BUDGET_USD ? { ok: true, v: raw } : { ok: false };
217
+ }
218
+ return { ok: false };
219
+ }
157
220
 
158
221
  function validateMatterAction(area, action) {
159
222
  if (action == null) return { ok: true, action: null };
160
- if (area !== 'people') return { ok: false, reason: 'action_not_supported_here', expected: 'only People can act by itself today (assign_rank / unassign_rank)' };
223
+ const acts = MATTER_ACTIONS[area];
224
+ if (!acts) return { ok: false, reason: 'action_not_supported_here', expected: `a matter acts by itself only at ${Object.keys(MATTER_ACTIONS).join(', ')}` };
161
225
  if (typeof action !== 'object' || Array.isArray(action)) return { ok: false, reason: 'bad_action' };
226
+ const shape = acts[action.type];
227
+ if (!shape) return { ok: false, reason: 'bad_action', expected: Object.keys(acts) };
162
228
  for (const k of Object.keys(action)) {
163
- if (!['type', 'builder_id', 'rank_key'].includes(k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
229
+ if (k !== 'type' && !Object.prototype.hasOwnProperty.call(shape, k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
164
230
  }
165
- if (!MATTER_ACTION_TYPES.includes(action.type)) return { ok: false, reason: 'bad_action', expected: MATTER_ACTION_TYPES };
166
- const builderId = Number(action.builder_id);
167
- if (!Number.isInteger(builderId) || builderId <= 0) return { ok: false, reason: 'bad_action', field: 'action.builder_id' };
168
- if (typeof action.rank_key !== 'string' || !/^[a-z][a-z0-9-]*$/.test(action.rank_key) || action.rank_key.length > 64) {
169
- return { ok: false, reason: 'bad_action', field: 'action.rank_key' };
231
+ const out = { type: action.type };
232
+ for (const [k, kind] of Object.entries(shape)) {
233
+ if (!Object.prototype.hasOwnProperty.call(action, k)) return { ok: false, reason: 'bad_action', field: `action.${k}` };
234
+ const f = fieldValue(kind, action[k]);
235
+ if (!f.ok) return { ok: false, reason: 'bad_action', field: `action.${k}` };
236
+ out[k] = f.v;
170
237
  }
171
- return { ok: true, action: { type: action.type, builder_id: builderId, rank_key: action.rank_key } };
238
+ return { ok: true, action: out };
239
+ }
240
+
241
+ // The owning module's act for (area, type), or null when that module is off.
242
+ function matterActFor(area, type) {
243
+ return api.contributions(MATTER_ACT_POINT)
244
+ .find((c) => c && c.area === area && c.type === type && typeof c.act === 'function') || null;
172
245
  }
173
246
 
174
247
  async function openMatter({ area, title, detailMd = null, action = null, proposedBy } = {}) {
@@ -190,7 +263,10 @@ async function openMatter({ area, title, detailMd = null, action = null, propose
190
263
  };
191
264
  }
192
265
  if (!resolved.sitting) return { ok: false, reason: 'no_one_holds_this', area, what_this_means: `No one holds ${resolved.name} yet, so nothing there can be decided.` };
193
- if (a.action) {
266
+ if (a.action && area !== 'people' && !matterActFor(area, a.action.type)) {
267
+ return { ok: false, reason: 'action_not_wired', area, what_this_means: `Nothing at ${resolved.name} can carry that out right now, so it would pass and do nothing. Turn on the module that owns ${resolved.name}, or file the matter without an act.` };
268
+ }
269
+ if (a.action && area === 'people') {
194
270
  const rank = await db.getRankByKey(a.action.rank_key);
195
271
  if (!rank) return { ok: false, reason: 'unknown_rank', rank_key: a.action.rank_key };
196
272
  if (rank.is_system) return { ok: false, reason: 'seeded_rank', rank_key: a.action.rank_key, expected: 'a custom rank — a seeded rank follows the builder\'s rank' };
@@ -295,6 +371,9 @@ module.exports = {
295
371
  proposeDecisionRules,
296
372
  decisionRulesView,
297
373
  validateMatterAction,
374
+ matterActFor,
375
+ MATTER_ACTIONS,
376
+ MATTER_ACT_POINT,
298
377
  openMatter,
299
378
  checkActAlone,
300
379
  };
@@ -101,6 +101,11 @@ const DESKS = Object.freeze([
101
101
  ['automation.switch', 'automation'], ['automation.goals', 'automation'], ['automation.runners', 'automation'],
102
102
  ['people.invite', 'people'], ['release.core', 'release'],
103
103
  ['rules.settings', 'rules'], ['rules.modules', 'rules'], ['treasury.backup', 'treasury'],
104
+ // the Planning desks (task 1004560): the goals in their worked order, and the
105
+ // ideas a ratifier may take from here
106
+ ['planning.order', 'planning'], ['planning.ratify', 'planning'],
107
+ // task 1004561: a builder's monthly spending limit, and a sign-off undone
108
+ ['treasury.budgets', 'treasury'], ['review.signed', 'review'],
104
109
  ].map(([key, building]) => Object.freeze({ key, building })));
105
110
 
106
111
  module.exports = {
@@ -250,8 +250,19 @@ function normalizeDeskRow(raw, { desk, building }) {
250
250
  opened_at: iso(raw.opened_at),
251
251
  ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
252
252
  acts,
253
+ ...deskExtras(raw),
253
254
  };
254
255
  }
256
+ // Two optional shapes a desk row may carry (task 1004560): `pips`, promises kept
257
+ // of total drawn as squares (at most 12), and `set`, the ordered set the row sits
258
+ // in (a version id) so the desk can reorder its rows and save the order whole.
259
+ function deskExtras(raw) {
260
+ const out = {};
261
+ const p = raw.pips;
262
+ if (p && Number.isInteger(p.of) && p.of > 0 && p.of <= 12 && Number.isInteger(p.on) && p.on >= 0 && p.on <= p.of) out.pips = { on: p.on, of: p.of };
263
+ if (typeof raw.set === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(raw.set)) out.set = raw.set;
264
+ return out;
265
+ }
255
266
 
256
267
  // One floor as a contributor handed it → { count, line, lines, hot }. Pure.
257
268
  // Anything that is not a plain object is not a floor (→ null, read as
@@ -110,6 +110,7 @@ const governmentPort = {
110
110
  // is no counterpart that records an outcome, and there must never be one: an
111
111
  // outcome is written by a close, never by the module reading it.
112
112
  boardPassForIdea: board.boardPassForIdea,
113
+ boardPassedIdeaIds: board.boardPassedIdeaIds,
113
114
  // task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
114
115
  // genesis home (task 1004423) calls this when a stage's tasks are done. The
115
116
  // finished-tasks gate lives inside board.js; the item is an ordinary one under
@@ -789,7 +790,7 @@ module.exports = function buildGovernmentRouter() {
789
790
  proposedBy: req.builder.id,
790
791
  });
791
792
  if (!out.ok) {
792
- if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this') {
793
+ if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this' || out.reason === 'action_not_wired') {
793
794
  return res.fail(out.reason, 409, { area: out.area, ...(out.gate ? { gate: out.gate } : {}), what_this_means: out.what_this_means });
794
795
  }
795
796
  return res.fail('invalid_matter', 400, {
@@ -512,6 +512,60 @@
512
512
  ACT_ROUTES.backup = { backup: function () { return post('/backup/trigger', {}); } };
513
513
  ACT_ROUTES.invite = { invite: function (id, inp) { return str(inp.option).trim() ? post('/access-requests/invite', { github_login: str(inp.option).trim() }) : null; } };
514
514
  ACT_ROUTES.autonomy_fence = ACT_ROUTES.autobongos_fence;
515
+ // The Planning desks (task 1004560). A blocker is decided by one of its chips,
516
+ // whose words are the resolution note; a criterion proposal ratifies in one
517
+ // tap; a Full Idea the board carried becomes a goal in the version its chip names.
518
+ ACT_ROUTES.blockers = {
519
+ decide: function (id, inp) { return str(inp.option).trim() ? post('/blockers/' + id + '/resolve', { resolution_note: 'Decided on the floor desk: ' + str(inp.option).trim() + '.' }) : null; },
520
+ };
521
+ // task 1004561: a builder's monthly limit (PATCH /builders/:id/budget; the
522
+ // 'none' chip clears it), and a sign-off undone (POST /done-when/:id/unsatisfy,
523
+ // the chip's words as the reason it logs).
524
+ ACT_ROUTES.builder_budget = {
525
+ budget: function (id, inp) { return inp.option ? call('PATCH', '/builders/' + id + '/budget', { monthly_budget_usd: inp.option === 'none' ? null : Number(inp.option) }) : null; },
526
+ };
527
+ ACT_ROUTES.signed_criterion = { unsign: function (id, inp) { return post('/done-when/' + id + '/unsatisfy', { reason: str(inp.label) || 'undone on the floor desk' }); } };
528
+ // The same acts at a building a sitting decides (task 1004561): filed as the
529
+ // building's matter, which carries the act out once it passes. Each returns
530
+ // { title, action } for POST /government/board/matters.
531
+ var COUNCIL_ACTS = {
532
+ autobongos_fence: {
533
+ switch_on: function () { return { title: 'Switch unattended building on', action: { type: 'switch_on' } }; },
534
+ switch_off: function () { return { title: 'Switch unattended building off', action: { type: 'switch_off' } }; },
535
+ allow: function (id, inp) { return inp.option ? { title: 'Allow ' + str(inp.label || 'goal ' + inp.option) + ' to build unattended', action: { type: 'allow_goal', goal_id: Number(inp.option) } } : null; },
536
+ },
537
+ criteria_uat: { force: function (id, inp, who) { return { title: 'Sign off: ' + who, action: { type: 'satisfy_criterion', criterion_id: Number(id) } }; } },
538
+ override_requests: { release: function (id, inp, who) { return { title: 'Release past its check: ' + who, action: { type: 'approve_override', request_id: Number(id) } }; } },
539
+ gate_approvals: { release: function (id, inp, who) { return { title: 'Release from the gate: ' + who, action: { type: 'approve_gate', pr: Number(id) } }; } },
540
+ builder_budget: {
541
+ budget: function (id, inp, who) { return inp.option ? { title: who + ': ' + str(inp.label || inp.option), action: { type: 'set_budget', builder_id: Number(id), monthly_usd: inp.option === 'none' ? null : Number(inp.option) } } : null; },
542
+ },
543
+ };
544
+ COUNCIL_ACTS.autonomy_fence = COUNCIL_ACTS.autobongos_fence;
545
+ COUNCIL_ACTS.autonomy_goal = { allow: COUNCIL_ACTS.autobongos_fence.allow };
546
+ function councilRequest(s, key, inp) {
547
+ var make = COUNCIL_ACTS[s.kind] && COUNCIL_ACTS[s.kind][key];
548
+ var m = make ? make(decoded(s.id), inp, str(s.who) || 'a matter') : null;
549
+ return m ? post('/government/board/matters', { area: s.council, title: str(m.title).slice(0, 200), action: m.action }) : null;
550
+ }
551
+ ACT_ROUTES.criterion_proposal = { ratify: function (id) { return post('/inbox/' + id + '/ratify', { notes_md: 'Ratified on the floor desk.' }); } };
552
+ ACT_ROUTES.full_idea = {
553
+ ratify_goal: function (id, inp) { return inp.option ? post('/inbox/' + id + '/ratify-goal', { target_version: str(inp.option), notes_md: 'Ratified on the floor desk.' }) : null; },
554
+ };
555
+ // The Priorities desk's save (task 1004560): the new order goes to
556
+ // POST /goals/reorder, or, where the decision table puts Planning to a sitting,
557
+ // it is filed as a Planning matter for the sitting to decide. save is the desk
558
+ // model's { set, ids, names, council }.
559
+ function orderRequest(save) {
560
+ var s = save || {};
561
+ var ids = (s.ids || []).map(Number).filter(function (n) { return n > 0 && Math.floor(n) === n; });
562
+ if (!ids.length) return null;
563
+ if (s.council) {
564
+ var lines = (s.names || []).map(function (n, i) { return (i + 1) + '. ' + str(n); });
565
+ return post('/government/board/matters', { area: 'planning', title: 'A new order for the goals', detail_md: 'Proposed on the floor desk, for version ' + str(s.set) + ':\n\n' + lines.join('\n') });
566
+ }
567
+ return post('/goals/reorder', { version_id: str(s.set), ordered_ids: ids });
568
+ }
515
569
  function decoded(encoded) { return decodeURIComponent(encoded); }
516
570
  // input: { reason, section } for object; { text } for revise.
517
571
  function request(cv, key, input) {
@@ -541,6 +595,7 @@
541
595
  if (key === 'fine') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'approve' });
542
596
  if (key === 'refuse') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'deny' });
543
597
  }
598
+ if (s.council && COUNCIL_ACTS[s.kind] && COUNCIL_ACTS[s.kind][key]) return councilRequest(s, key, inp);
544
599
  if (ACT_ROUTES[s.kind] && ACT_ROUTES[s.kind][key]) return ACT_ROUTES[s.kind][key](enc(s.id), inp);
545
600
  if (s.kind === 'override_requests' && key === 'release') return post('/override-requests/' + enc(s.id) + '/decide', { decision: 'approve' });
546
601
  if (s.kind === 'gate_approvals' && key === 'release') return post('/gate-approvals/' + enc(s.id) + '/approve', {});
@@ -565,6 +620,9 @@
565
620
  var r = res || {};
566
621
  var who = cv && cv.subject && cv.subject.who ? cv.subject.who : 'They';
567
622
  var b = cv ? cv.building : null;
623
+ if (cv && cv.subject && cv.subject.council && COUNCIL_ACTS[cv.subject.kind] && COUNCIL_ACTS[cv.subject.kind][key]) {
624
+ return { toast: 'Sent to the council. It is carried out when they pass it.', building: b, kind: 'light', close: false };
625
+ }
568
626
  if (key === 'for') {
569
627
  if (r.item_closed && r.outcome === 'passed') return { toast: 'Passed. It is in force from today.', building: b, kind: 'rise', close: false };
570
628
  if (r.item_closed) return { toast: 'The sitting closed: it went back to its author.', building: b, kind: 'settle', close: false };
@@ -594,8 +652,10 @@
594
652
  tier: '{who} has a new tier.', deactivate: '{who} is deactivated.', give: 'Rank given.', take: 'Rank taken back.',
595
653
  check: 'Checking it. The answer lands on this floor in a couple of minutes; nothing moves.', take_core: 'Taking it. The core moves on the runner\'s next tick.',
596
654
  rot: 'Rot timer set.', module_on: 'Turned on.', module_off: 'Turned off.', backup: 'Backing up now.', invite: '{who} is invited.',
655
+ decide: 'Decided. The work it held moves again.', ratify: 'Ratified. The promise is on its version.', ratify_goal: 'Ratified. It is a goal now.',
656
+ budget: '{who} has a new monthly limit.', unsign: 'Undone. The promise is open again.',
597
657
  };
598
- var ACT_KINDS = { check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
658
+ var ACT_KINDS = { budget: 'light', unsign: 'settle', decide: 'rise', ratify: 'rise', ratify_goal: 'rise', check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
599
659
 
600
660
  // A refused act, in words. The server's own message wins when it has one.
601
661
  var REFUSALS = {
@@ -786,7 +846,7 @@
786
846
 
787
847
  var api = {
788
848
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
789
- model: model, actsCard: actsCard, request: request, check: check, answer: answer, refusal: refusal, render: render,
849
+ model: model, actsCard: actsCard, request: request, orderRequest: orderRequest, check: check, answer: answer, refusal: refusal, render: render,
790
850
  memberWords: memberWords, windowWords: windowWords,
791
851
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
792
852
  change: changeOf,
@@ -38,10 +38,20 @@
38
38
  // GovDesk.fit(s, n, words) the line formatter
39
39
  // GovDesk.breaks(desk) [] when the desk keeps the line rule
40
40
  // GovDesk.todayPips(n) the street header's meter
41
+ // GovDesk.moved(order, key, by) a desk order with one row moved
41
42
  // GovDesk.LIMITS, ACT_WORDS, CHIPS, NOUNS, CLEAR
42
43
  // A desk row is { key, matter, ini, name, fact, facts[], actions[{ key, label,
43
44
  // tone, go, ask }], chips { for, ask, options[{ key, label, input }] } | null,
44
45
  // wait, needs, card }: `card` is the GovMatterCard model the act is sent with.
46
+ //
47
+ // THE PLANNING DESKS (task 1004560). A row may carry `pips` [{ key, on }]: a
48
+ // goal's promises kept of total, or a vote's seats (filled = a yes). A row of an
49
+ // ordered set (the Priorities desk: `order` is its version) is moved with its
50
+ // two arrows; input.order is the order the viewer has made, and a changed order
51
+ // gives the desk a `save` { line, label, set, ids, names, council }: the
52
+ // council when the decision table puts Planning to a sitting that cannot act
53
+ // alone, so the save files a Planning matter instead of setting the order. A
54
+ // blocker's chips stand open (`show`): deciding it IS picking one.
45
55
 
46
56
  (function (root) {
47
57
  'use strict';
@@ -54,7 +64,7 @@
54
64
  nameWords: 3, name: 20, fact: 26, facts: 3, factLen: 24, actions: 2,
55
65
  tiles: 3, tileValue: 8, tileWords: 2, tileLabel: 14,
56
66
  status: 44, clearLine: 30, clearSub: 40, record: 4, recordLen: 30,
57
- chips: 5, chip: 16, todayTarget: 10,
67
+ chips: 5, chip: 16, todayTarget: 10, pips: 12, saveLine: 48,
58
68
  });
59
69
 
60
70
  // One line, never more: the house rule for every row, tile and status. A cap
@@ -105,15 +115,29 @@
105
115
  switch_on: 'switch on', switch_off: 'switch off', allow: 'allow', disallow: 'disallow', first: 'first',
106
116
  pause: 'pause', resume: 'resume', tier: 'tier', deactivate: 'deactivate', give: 'give', take: 'take back',
107
117
  check: 'check', take_core: 'take it', rot: 'set', module_on: 'turn on', module_off: 'turn off', backup: 'back up', invite: 'invite', open: 'open',
118
+ // the Planning desks (task 1004560)
119
+ decide: 'decide', ratify: 'ratify', ratify_goal: 'make goal', up: 'move up', down: 'move down',
120
+ // task 1004561: a builder's monthly limit, and a sign-off undone
121
+ budget: 'set limit', unsign: 'undo',
108
122
  });
109
123
  // What a row's chips ask, per act (a reason or an option is one tap, never text).
110
- const ASKS = Object.freeze({ check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
124
+ const ASKS = Object.freeze({ budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
111
125
  // How a floor counts rows nobody is waiting on (desk rows), by its desk.
112
126
  const DESK_NOUNS = Object.freeze({
113
127
  'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
114
128
  'automation.switch': 'switch', 'automation.goals': 'allowed', 'automation.runners': 'runners',
115
129
  'people.invite': 'invites', 'release.core': 'update', 'rules.settings': 'settings', 'rules.modules': 'modules', 'treasury.backup': 'backups',
130
+ 'planning.order': 'goals in order', 'planning.ratify': 'to ratify',
131
+ 'treasury.budgets': 'builders', 'review.signed': 'signed off',
116
132
  });
133
+ // Is this building decided by a sitting that cannot act alone (task 1004561)?
134
+ // The decision table's row, as the docket's buildings carry it: then a desk act
135
+ // is filed as the building's matter, and carried out once the sitting passes it.
136
+ function councilOf(docket, key) {
137
+ const b = docket && Array.isArray(docket.buildings) ? docket.buildings.find((x) => x && x.key === key) : null;
138
+ const d = b && b.decision ? b.decision : null;
139
+ return !!(d && d.sitting && ['decide', 'first'].indexOf(d.rule) < 0);
140
+ }
117
141
  // An objection's reason as one tap: the five sections the server accepts
118
142
  // (street-card.js SECTIONS), each in at most 16 characters.
119
143
  const CHIPS = Object.freeze({ scope: 'what it changes', purpose: 'its purpose', why_matters: 'why it matters', fit: 'how it fits', success: 'how to measure' });
@@ -185,6 +209,8 @@
185
209
  });
186
210
  }
187
211
  const cv = C && C.model ? C.model({ docket: ctx.docket, key: m.id, room: ctx.room, constitution: ctx.constitution, now: now, viewerId: ctx.viewerId }) : null;
212
+ // a release held for a sitting goes to it as a matter (task 1004561)
213
+ if (cv && cv.subject && cv.subject.itemId == null) cv.subject.council = councilOf(ctx.docket, cv.building) ? cv.building : null;
188
214
  const actions = [];
189
215
  let chips = null;
190
216
  if (cv && !cv.readOnly && !cv.loading) {
@@ -200,6 +226,8 @@
200
226
  return {
201
227
  key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
202
228
  actions: actions, chips: chips, choices: chips ? { [chips.for]: chips } : {}, wait: wait, needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null, card: cv,
229
+ // a vote shows its seats: one pip each, filled for a yes
230
+ pips: cv && Array.isArray(cv.seats) && cv.seats.length ? cv.seats.slice(0, LIMITS.pips).map((s, i) => ({ key: 's' + i, on: s.pos === 'for' })) : null,
203
231
  };
204
232
  }
205
233
 
@@ -210,6 +238,8 @@
210
238
  // goes to the matter's own page (a sign-off that needs a recording).
211
239
  function actedRow(acts, card, ctx) {
212
240
  const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || 'goal ' + g.id)]));
241
+ // an act at a building a sitting decides is filed as its matter (task 1004561)
242
+ if (card && card.subject) card.subject.council = councilOf(ctx.docket, card.building) ? card.building : null;
213
243
  const actions = [];
214
244
  const choices = {};
215
245
  acts.forEach((a) => {
@@ -232,7 +262,9 @@
232
262
  actions.push({ key: a.key, label: ACT_WORDS[word], tone: actions.length ? 'quiet' : 'primary', go: goes ? card.href : null, ask: ask });
233
263
  });
234
264
  const first = actions.find((a) => a.ask);
235
- return { actions: actions, chips: first ? choices[first.key] : null, choices: choices, wait: actions.length ? '' : 'not from here', card: card };
265
+ // deciding a blocker is picking one of its options: its chips stand open
266
+ const show = first && first.key === 'decide' && choices.decide ? 'decide' : null;
267
+ return { actions: actions, chips: first ? choices[first.key] : null, choices: choices, show: show, wait: actions.length ? '' : 'not from here', card: card };
236
268
  }
237
269
 
238
270
  // A desk row (task 1004559): something on this floor the viewer may act on that
@@ -253,6 +285,8 @@
253
285
  return Object.assign(actedRow(d.acts || [], card || { href: null }, ctx), {
254
286
  key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
255
287
  needs: d.needs === true, source: d.desk, opened: d.opened_at || null, desk: true, tool: d.tool === true, over: d.over || null,
288
+ order: d.set || null, goalId: d.set && ref.id != null ? str(ref.id) : null, full: full,
289
+ pips: d.pips ? Array.from({ length: Math.min(d.pips.of, LIMITS.pips) }, (_, i) => ({ key: 'p' + i, on: i < d.pips.on })) : null,
256
290
  });
257
291
  }
258
292
  // The desk rows of one floor: those of the desk its PLAN entry names.
@@ -292,13 +326,27 @@
292
326
  const spec = plan.floors[floor];
293
327
  const fl = I.floorFor(spec, { key: o.key, docket: docket, goals: o.goals || [], now: o.now || 0 });
294
328
  const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done, goals: o.goals || [] };
295
- const rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
329
+ let rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
330
+ // the Priorities desk: the ordered rows in the order the viewer has made
331
+ const ordered = rows.filter((r) => r.order);
332
+ let save = null;
333
+ if (ordered.length > 1) {
334
+ const base = ordered.map((r) => r.key);
335
+ const want = Array.isArray(o.order) ? o.order.map(str) : null;
336
+ const mine = want && want.length === base.length && base.every((k) => want.indexOf(k) >= 0) ? want : base;
337
+ const sorted = mine.map((k) => ordered.find((r) => r.key === k));
338
+ sorted.forEach((r, i) => { r.pos = i + 1; r.top = i === 0; r.bottom = i === sorted.length - 1; });
339
+ rows = rows.filter((r) => !r.order).concat(sorted);
340
+ if (mine.join('|') !== base.join('|')) save = saveOf(docket, o.key, sorted);
341
+ }
296
342
  const bind = spec[2] || {};
297
343
  const src = bind.source ? bind.source : bind.floor === 'planning.ideas' ? 'idea' : floor === 0 ? 'board' : null;
298
344
  const needs = rows.filter((r) => r.needs);
299
345
  const noun = NOUNS[src] || 'open';
300
346
  // rows nobody waits on: 'moving' for matters, the desk's own noun for desk rows
301
- const moving = rows.length && rows.every((r) => r.desk) ? (DESK_NOUNS[rows[0].source] || 'here') : 'moving';
347
+ // (rows waiting on no one, when all of them are desk rows, count in their desk's noun: 'to ratify')
348
+ const rest = rows.filter((r) => !r.needs && !r.tool);
349
+ const moving = rest.length && rest.every((r) => r.desk) ? (DESK_NOUNS[rest[0].source] || 'here') : 'moving';
302
350
  const counted = rows.filter((r) => !r.tool);
303
351
  const oldestOf = (list) => list.reduce((a, r) => (r.opened && (!a || Date.parse(r.opened) < Date.parse(a)) ? r.opened : a), null);
304
352
  const cleared = !!o.cleared && rows.length === 0;
@@ -320,7 +368,13 @@
320
368
  const words = str(l).replace(/\s+/g, ' ').trim().split(' ').slice(0, LIMITS.tileWords).join(' ').replace(/[,.;:·-]+$/, '');
321
369
  tiles.push({ key: 't' + tiles.length, v: fit(val, LIMITS.tileValue), l: fit(words, LIMITS.tileLabel) });
322
370
  };
323
- if (rows.length) { tile(needs.length, noun); tile(counted.length - needs.length, moving); }
371
+ if (ordered.length > 1) {
372
+ // the order's one number: the share of its goals' promises kept
373
+ const on = ordered.reduce((a, r) => a + (r.pips || []).filter((p) => p.on).length, 0);
374
+ const of = ordered.reduce((a, r) => a + (r.pips || []).length, 0);
375
+ tile(needs.length, noun);
376
+ if (of) tile(Math.round((100 * on) / of) + '%', 'promises kept');
377
+ } else if (rows.length) { tile(needs.length, noun); tile(counted.length - needs.length, moving); }
324
378
  else if (fl.count) tile(fl.count, str(fl.line).replace(/^[$]?[0-9][0-9,.%]*\s*/, '') || 'on this floor');
325
379
  tile(history.length, 'on record');
326
380
  const record = (o.mine || []).map((t, i) => ({ key: 'm' + i, text: fit(t + ' · now', LIMITS.recordLen), mine: true }))
@@ -330,13 +384,50 @@
330
384
  key: o.key + ':' + floor, building: o.key, floor: floor, name: spec[0], sym: o.sym || { d: '', f: '' },
331
385
  status: fit(status, LIMITS.status), hot: hot,
332
386
  tiles: tiles.slice(0, LIMITS.tiles), rows: rows,
333
- quiet: quiet, cleared: cleared,
387
+ quiet: quiet, cleared: cleared, save: save,
334
388
  clearLine: fit(cleared ? (CLEAR[src] || 'Floor clear') : 'Nothing needs you here', LIMITS.clearLine),
335
389
  clearSub: fit(cleared ? 'lit up · ' + (o.today || 0) + ' decided today' : fl.line, LIMITS.clearSub),
336
390
  record: record,
337
391
  };
338
392
  }
339
393
 
394
+ // ---------- the Priorities desk's save ----------
395
+ // Who decides the order: the decision table's Planning row, as the docket's
396
+ // buildings carry it. A sitting whose rule cannot act alone means the save
397
+ // files a matter for it; the yes it needs is read from its seats.
398
+ function yesNeeded(rule, n) {
399
+ if (!(n > 0)) return null;
400
+ if (rule === 'majority') return Math.floor(n / 2) + 1;
401
+ if (rule === 'unanimous') return n;
402
+ if (rule === 'signoff') return 1;
403
+ return null;
404
+ }
405
+ function saveOf(docket, key, sorted) {
406
+ const b = docket && Array.isArray(docket.buildings) ? docket.buildings.find((x) => x && x.key === key) : null;
407
+ const d = b && b.decision ? b.decision : null;
408
+ const council = councilOf(docket, key);
409
+ const yes = council ? yesNeeded(d.rule, Number(b.holder_count)) : null;
410
+ const line = !council ? 'new order · saved for everyone'
411
+ : d.rule === 'consent' ? 'new order · to the council · unless one objects'
412
+ : 'new order · goes to the council' + (yes ? ' · ' + yes + ' yes needed' : '');
413
+ // what the toast says once it is sent: whole sentences, never the line cut up
414
+ const said = !council ? 'Saved. Goals are worked in this order now.'
415
+ : d.rule === 'consent' ? 'The new order is with the council. It passes unless someone objects.'
416
+ : 'The new order is with the council.' + (yes ? ' It needs ' + yes + ' yes.' : '');
417
+ return {
418
+ line: fit(line, LIMITS.saveLine), label: council ? 'send to council' : 'save order', council: council, said: said,
419
+ set: sorted[0].order, ids: sorted.map((r) => r.goalId), names: sorted.map((r) => r.full || r.name),
420
+ };
421
+ }
422
+ // An order with one row moved one place (by -1 up, +1 down); the ends hold.
423
+ function moved(order, key, by) {
424
+ const o = (order || []).slice();
425
+ const i = o.indexOf(key), j = i + by;
426
+ if (i < 0 || j < 0 || j >= o.length) return o;
427
+ o[i] = o[j]; o[j] = key;
428
+ return o;
429
+ }
430
+
340
431
  // ---------- the rule as a checker ----------
341
432
  function breaks(desk) {
342
433
  const out = [];
@@ -364,7 +455,9 @@
364
455
  if ((!c.field && !c.options.length) || c.options.length > LIMITS.chips || c.options.some((x) => !x.label || x.label.length > LIMITS.chip)) bad('chips', c.options.map((x) => x.label));
365
456
  });
366
457
  if (r.actions.some((a) => a.ask && !(r.choices || {})[a.key])) bad('an ask without its own chips', r.name);
458
+ if (r.pips && r.pips.length > LIMITS.pips) bad('more than twelve pips', r.pips.length);
367
459
  });
460
+ if (desk.save && (!oneLine(desk.save.line) || desk.save.line.length > LIMITS.saveLine)) bad('save line', desk.save.line);
368
461
  if (desk.record.length > LIMITS.record || desk.record.some((x) => !oneLine(x.text) || x.text.length > LIMITS.recordLen)) bad('record line', desk.record.map((x) => x.text));
369
462
  return out;
370
463
  }
@@ -379,7 +472,7 @@
379
472
 
380
473
  const api = {
381
474
  LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
382
- fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips,
475
+ fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
383
476
  };
384
477
  root.GovDesk = api;
385
478
  if (typeof module !== 'undefined' && module.exports) module.exports = api;