@bongos/core 1.21.34 → 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 (47) hide show
  1. package/.bongos-core.json +65 -50
  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 +4 -0
  6. package/docs/page-inventory.json +10 -1
  7. package/docs/page-readings.json +356 -251
  8. package/modules/autonomy/governor-docket.js +68 -1
  9. package/modules/autonomy/matter-acts.js +41 -0
  10. package/modules/autonomy/routes/autonomy.js +2 -0
  11. package/modules/government/board.js +4 -0
  12. package/modules/government/db.js +14 -0
  13. package/modules/government/decision-board.js +90 -11
  14. package/modules/government/docket-floors.js +20 -0
  15. package/modules/government/docket-vocab.js +19 -0
  16. package/modules/government/docket.js +114 -2
  17. package/modules/government/routes/government.js +15 -1
  18. package/modules/hall-ui/public/street-card.js +157 -1
  19. package/modules/hall-ui/public/street-desk.js +205 -11
  20. package/modules/hall-ui/public/street-inside.js +11 -8
  21. package/modules/hall-ui/public/street.css +16 -0
  22. package/modules/hall-ui/public/street.js +97 -9
  23. package/modules/hall-ui/public/street.states.json +355 -6
  24. package/modules/ideas/governor-docket.js +91 -0
  25. package/modules/lifecycle/governor-docket.js +93 -2
  26. package/modules/lifecycle/matter-acts.js +63 -0
  27. package/modules/lifecycle/routes/done-when.js +26 -12
  28. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  29. package/modules/lifecycle/routes/lifecycle.js +2 -0
  30. package/modules/lifecycle/routes/project-settings.js +24 -0
  31. package/modules/onboarding/routes/access-requests.js +7 -0
  32. package/modules/provisioning/core-upgrade.js +1 -1
  33. package/modules/provisioning/routes/core-upgrade.js +36 -1
  34. package/modules/security/routes/reports.js +23 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +16 -0
  38. package/src/bongos/route-rank-check.js +2 -0
  39. package/src/bongos/routes/backup.js +26 -0
  40. package/src/bongos/routes/builders.js +96 -1
  41. package/src/bongos/routes/modules.js +18 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/governor_docket.mjs +28 -1
  44. package/tests/governor_matter_acts.mjs +241 -0
  45. package/tests/hall_matter_card.mjs +11 -2
  46. package/tests/hall_street_desk.mjs +473 -4
  47. package/tests/hall_street_inside.mjs +3 -3
@@ -440,8 +440,133 @@
440
440
  return sourceCard(cv, m);
441
441
  }
442
442
 
443
+ // ---------- the acts the server hands a row (task 1004559) ----------
444
+ // A docket matter or desk row may carry `acts`: what THIS viewer may do, which
445
+ // the owning module decided from the same atom its route checks. The floor
446
+ // desk sends them through this card: { kind, id, who } is all a route needs.
447
+ // kind is the matter's source, or the desk row's ref type.
448
+ function actsCard(o) {
449
+ var x = o || {};
450
+ return {
451
+ key: str(x.key), building: x.building || null, where: '', when: '', title: str(x.title), href: safeHref(x.href),
452
+ source: str(x.kind), quote: null, change: null, revision: null, seats: [], tally: '', voices: [], counsel: null,
453
+ result: '', actions: [], textActions: [], objectForm: null, reviseForm: null, readOnly: false, loading: false, lines: [],
454
+ subject: { kind: str(x.kind), id: str(x.id), who: str(x.who) },
455
+ };
456
+ }
457
+
443
458
  // ---------- act → route ----------
444
459
  function post(url, body) { return { method: 'POST', url: API + url, body: body || {} }; }
460
+ function call(method, url, body) { return { method: method, url: API + url, body: body || {} }; }
461
+ // The routes behind the server-handed acts. inp.option is the chip's key, and
462
+ // inp.label its words (sent as the reason where a route keeps one).
463
+ var ACT_ROUTES = {
464
+ redteam_reports: {
465
+ confirm: function (id) { return post('/security/reports/' + id + '/confirm', {}); },
466
+ dispute: function (id) { return post('/security/reports/' + id + '/dispute', {}); },
467
+ },
468
+ vulnerability_report: { fix: function (id) { return post('/security/reports/' + id + '/fix', {}); } },
469
+ removal_proposals: {
470
+ remove: function (id) { return post('/removal-proposals/' + id + '/decide', { verdict: 'confirmed' }); },
471
+ keep: function (id) { return post('/removal-proposals/' + id + '/decide', { verdict: 'dismissed' }); },
472
+ },
473
+ criteria_uat: {
474
+ signoff: function (id) { return post('/done-when/' + id + '/uat', { kind: 'backend_signoff', live_attested: true }); },
475
+ force: function (id, inp) { return post('/done-when/' + id + '/satisfy', { override_reason: 'Signed off on the floor desk: ' + str(inp.label || 'forced') + '.' }); },
476
+ },
477
+ autobongos_fence: {
478
+ switch_on: function () { return post('/autonomy/fence', { enabled: true }); },
479
+ switch_off: function (id, inp) { return post('/autonomy/fence', { enabled: false, reason: str(inp.label) || 'switched off on the floor desk' }); },
480
+ allow: function (id, inp) { return inp.option ? post('/autonomy/fence/goals', { goal_id: Number(inp.option) }) : null; },
481
+ },
482
+ autonomy_goal: {
483
+ first: function (id) { return post('/autonomy/fence/priority', { goal_id: Number(id) }); },
484
+ disallow: function (id) { return call('DELETE', '/autonomy/fence/goals/' + id); },
485
+ allow: function (id, inp) { return inp.option ? post('/autonomy/fence/goals', { goal_id: Number(inp.option) }) : null; },
486
+ },
487
+ autonomy_runner: {
488
+ pause: function (id, inp) { return post('/autonomy/builders/' + id + '/pause', { paused: true, reason: str(inp.label) || 'paused on the floor desk' }); },
489
+ resume: function (id) { return post('/autonomy/builders/' + id + '/pause', { paused: false }); },
490
+ },
491
+ builder: {
492
+ tier: function (id, inp) { return inp.option ? call('PATCH', '/builders/' + id + '/rank', { rank: str(inp.option) }) : null; },
493
+ deactivate: function (id, inp) { return call('PATCH', '/builders/' + id + '/status', { status: 'inactive', reason: str(inp.label) || 'deactivated on the floor desk' }); },
494
+ },
495
+ custom_rank: {
496
+ give: function (id, inp) { return inp.option ? post('/government/assignments', { builder_id: Number(inp.option), rank_key: decoded(id) }) : null; },
497
+ take: function (id, inp) { return inp.option ? call('DELETE', '/government/assignments/' + encodeURIComponent(str(inp.option)) + '/' + id) : null; },
498
+ },
499
+ };
500
+ // The core update's two steps (POST /provisioning/instances/:id/core-upgrade):
501
+ // the server hands 'take' only once a dry run passed for that very version, and
502
+ // the chip carries the version, so no card moves the pin without that check.
503
+ ACT_ROUTES.core_pin = {
504
+ check: function (id, inp) { return inp.option ? post('/provisioning/instances/' + id + '/core-upgrade', { to: str(inp.option), dry_run: true }) : null; },
505
+ take: function (id, inp) { return inp.option ? post('/provisioning/instances/' + id + '/core-upgrade', { to: str(inp.option) }) : null; },
506
+ };
507
+ ACT_ROUTES.project_setting = { rot: function (id, inp) { return inp.option ? call('PATCH', '/project-settings/rot-days', { days: Number(inp.option) }) : null; } };
508
+ ACT_ROUTES.module = {
509
+ module_on: function (id) { return post('/modules/' + id + '/enable', { enabled: true }); },
510
+ module_off: function (id) { return post('/modules/' + id + '/enable', { enabled: false }); },
511
+ };
512
+ ACT_ROUTES.backup = { backup: function () { return post('/backup/trigger', {}); } };
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
+ 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
+ }
569
+ function decoded(encoded) { return decodeURIComponent(encoded); }
445
570
  // input: { reason, section } for object; { text } for revise.
446
571
  function request(cv, key, input) {
447
572
  if (!cv || !cv.subject) return null;
@@ -470,6 +595,8 @@
470
595
  if (key === 'fine') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'approve' });
471
596
  if (key === 'refuse') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'deny' });
472
597
  }
598
+ if (s.council && COUNCIL_ACTS[s.kind] && COUNCIL_ACTS[s.kind][key]) return councilRequest(s, key, inp);
599
+ if (ACT_ROUTES[s.kind] && ACT_ROUTES[s.kind][key]) return ACT_ROUTES[s.kind][key](enc(s.id), inp);
473
600
  if (s.kind === 'override_requests' && key === 'release') return post('/override-requests/' + enc(s.id) + '/decide', { decision: 'approve' });
474
601
  if (s.kind === 'gate_approvals' && key === 'release') return post('/gate-approvals/' + enc(s.id) + '/approve', {});
475
602
  return null; // hold, abstain and the core's release (a link) record nothing here
@@ -493,6 +620,9 @@
493
620
  var r = res || {};
494
621
  var who = cv && cv.subject && cv.subject.who ? cv.subject.who : 'They';
495
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
+ }
496
626
  if (key === 'for') {
497
627
  if (r.item_closed && r.outcome === 'passed') return { toast: 'Passed. It is in force from today.', building: b, kind: 'rise', close: false };
498
628
  if (r.item_closed) return { toast: 'The sitting closed: it went back to its author.', building: b, kind: 'settle', close: false };
@@ -505,9 +635,28 @@
505
635
  if (key === 'release') return { toast: cv && cv.source === 'gate_approvals' ? 'Released from the gate. It merges and goes live.' : 'Released. It goes live.', building: b, kind: 'rise', close: true };
506
636
  if (key === 'fine') return { toast: cv && cv.source === 'goal_join_requests' ? 'Noted. ' + who + ' joins the team.' : 'Noted. ' + who + ' is invited in.', building: b, kind: 'rise', close: false };
507
637
  if (key === 'refuse') return { toast: 'Noted. ' + who + ' is not let in.', building: b, kind: 'settle', close: false };
638
+ if (key === 'take' && cv && cv.subject && cv.subject.kind === 'core_pin') return { toast: ACT_TOASTS.take_core, building: b, kind: 'rise', close: false };
639
+ if (key === 'invite') return { toast: ACT_TOASTS.invite.replace('{who}', r.github_login || 'They'), building: b, kind: 'light', close: false };
640
+ if (ACT_TOASTS[key]) return { toast: ACT_TOASTS[key].replace('{who}', who), building: b, kind: ACT_KINDS[key] || 'light', close: false };
508
641
  return { toast: '', building: null, kind: null, close: false };
509
642
  }
510
643
 
644
+ // What the street says after a server-handed act (task 1004559).
645
+ var ACT_TOASTS = {
646
+ confirm: 'Confirmed. The report is on the record.', dispute: 'Disputed. The report is closed as not a flaw.', fix: 'Marked fixed.',
647
+ remove: 'Removal confirmed.', keep: 'Kept. The removal is dismissed.',
648
+ signoff: 'Signed off. The promise counts as kept.', force: 'Signed off by force. The reason is on the record.',
649
+ switch_on: 'Switched on. Unattended building can start.', switch_off: 'Switched off. Nothing new starts unattended.',
650
+ allow: 'Allowed. It may build that goal now.', disallow: 'No longer allowed.', first: 'First in line now.',
651
+ pause: '{who} is paused.', resume: '{who} may run again.',
652
+ tier: '{who} has a new tier.', deactivate: '{who} is deactivated.', give: 'Rank given.', take: 'Rank taken back.',
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.',
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.',
657
+ };
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' };
659
+
511
660
  // A refused act, in words. The server's own message wins when it has one.
512
661
  var REFUSALS = {
513
662
  not_board_member: 'Only the members of this sitting may vote on it.',
@@ -522,6 +671,13 @@
522
671
  proposer_would_lose_their_seat: 'Re-filing this would take your own seat. Change it in the Board Room.',
523
672
  no_change: 'That already is how it works.',
524
673
  preview_readonly: 'This preview records nothing.',
674
+ decided_by_sitting: 'The council decides this now. Put it to a sitting.',
675
+ cannot_change_own_status: 'Ask another archon to change your own standing.',
676
+ already_member: 'They already have an account here.',
677
+ bad_github_login: 'That does not look like a GitHub name.',
678
+ backup_already_running: 'A backup is already running.',
679
+ intent_conflict: 'Another core run is going. Try again when it settles.',
680
+ criterion_needs_recording: 'This one needs a recording. Sign it off on its goal page.',
525
681
  };
526
682
  function refusal(status, body) {
527
683
  var e = body && body.error ? body.error : {};
@@ -690,7 +846,7 @@
690
846
 
691
847
  var api = {
692
848
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
693
- model: model, 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,
694
850
  memberWords: memberWords, windowWords: windowWords,
695
851
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
696
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
@@ -97,7 +107,37 @@
97
107
  gate_approvals: 'Everything ready is live', blockers: 'Nothing blocked on you', board: 'Every vote is in', idea: 'No ideas waiting',
98
108
  });
99
109
  // The card's acts under the desk's words (the board's button labels).
100
- const ACT_WORDS = Object.freeze({ for: 'yes', object: 'no', fine: 'let in', refuse: 'not now', release: 'release', hold: 'hold' });
110
+ const ACT_WORDS = Object.freeze({
111
+ for: 'yes', object: 'no', fine: 'let in', refuse: 'not now', release: 'release', hold: 'hold',
112
+ // the acts the server hands a row (task 1004559), each its old page's route
113
+ confirm: 'confirm', dispute: 'dispute', fix: 'fixed', remove: 'remove', keep: 'keep',
114
+ signoff: 'sign off', record: 'record it', force: 'force',
115
+ switch_on: 'switch on', switch_off: 'switch off', allow: 'allow', disallow: 'disallow', first: 'first',
116
+ pause: 'pause', resume: 'resume', tier: 'tier', deactivate: 'deactivate', give: 'give', take: 'take back',
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',
122
+ });
123
+ // What a row's chips ask, per act (a reason or an option is one tap, never text).
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' });
125
+ // How a floor counts rows nobody is waiting on (desk rows), by its desk.
126
+ const DESK_NOUNS = Object.freeze({
127
+ 'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
128
+ 'automation.switch': 'switch', 'automation.goals': 'allowed', 'automation.runners': 'runners',
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',
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
+ }
101
141
  // An objection's reason as one tap: the five sections the server accepts
102
142
  // (street-card.js SECTIONS), each in at most 16 characters.
103
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' });
@@ -161,7 +201,16 @@
161
201
  if (when) add('closes ' + when);
162
202
  if (m.where) add(str(m.where).toLowerCase());
163
203
  add(STATE[stateOf(m)]);
204
+ if (Array.isArray(m.acts) && m.acts.length && C && C.actsCard) {
205
+ const ac = C.actsCard({ key: m.id, kind: m.source, id: m.ref && m.ref.id != null ? m.ref.id : str(m.id).replace(/^[^:]*:/, ''), who: person || name, title: m.title, building: m.building, href: m.href });
206
+ return Object.assign(actedRow(m.acts, ac, ctx), {
207
+ key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
208
+ needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null,
209
+ });
210
+ }
164
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;
165
214
  const actions = [];
166
215
  let chips = null;
167
216
  if (cv && !cv.readOnly && !cv.loading) {
@@ -176,13 +225,90 @@
176
225
  const wait = actions.length ? '' : cv && cv.loading ? 'reading…' : stateOf(m) === 'needs' ? 'not from here' : 'moving';
177
226
  return {
178
227
  key: str(m.id), matter: str(m.id), ini: initials(name), name: name, fact: fact, facts: facts,
179
- actions: actions, chips: chips, wait: wait, needs: stateOf(m) === 'needs', source: m.source, opened: m.opened_at || null, card: cv,
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,
180
231
  };
181
232
  }
233
+
234
+ // The buttons and chips of a row whose acts the server handed it (task
235
+ // 1004559). An act with options asks first, with its options as one-tap chips;
236
+ // `pick: 'open_goals'` offers the open goals the street already reads; an act
237
+ // whose chips come out empty is left off rather than drawn dead. 'record'
238
+ // goes to the matter's own page (a sign-off that needs a recording).
239
+ function actedRow(acts, card, ctx) {
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;
243
+ const actions = [];
244
+ const choices = {};
245
+ acts.forEach((a) => {
246
+ // the core's 'take' is its own word ('take it'), not the rank's 'take back'
247
+ const word = a.key === 'take' && card.subject && card.subject.kind === 'core_pin' ? 'take_core' : a.key;
248
+ if (actions.length >= LIMITS.actions || !ACT_WORDS[word]) return;
249
+ let options = (a.options || []).map((o) => ({ key: str(o.key), label: fit(o.label, LIMITS.chip), input: { option: str(o.key), label: str(o.label) } }));
250
+ if (a.pick === 'open_goals') {
251
+ const except = new Set((a.except || []).map(str));
252
+ options = (ctx.goals || []).filter((g) => g && !except.has(str(g.id))).slice(0, LIMITS.chips)
253
+ .map((g) => ({ key: str(g.id), label: fit(titles.get(str(g.id)), LIMITS.chip), input: { option: str(g.id), label: titles.get(str(g.id)) } }));
254
+ if (!options.length) return;
255
+ }
256
+ options = options.slice(0, LIMITS.chips);
257
+ const goes = a.key === 'record' || a.key === 'open';
258
+ if (goes && !card.href) return;
259
+ const field = a.field || null;
260
+ const ask = options.length > 0 || !!field;
261
+ if (ask) choices[a.key] = field ? { for: a.key, ask: ASKS[word] || 'which', field: field, options: [] } : { for: a.key, ask: ASKS[word] || 'which', options: options };
262
+ actions.push({ key: a.key, label: ACT_WORDS[word], tone: actions.length ? 'quiet' : 'primary', go: goes ? card.href : null, ask: ask });
263
+ });
264
+ const first = actions.find((a) => a.ask);
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 };
268
+ }
269
+
270
+ // A desk row (task 1004559): something on this floor the viewer may act on that
271
+ // no one is waiting on, read by the module that owns its route.
272
+ function deskRowOf(d, ctx) {
273
+ const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || '')]));
274
+ const hit = /^goal (\d+)$/.exec(str(d.name));
275
+ const full = hit && titles.get(hit[1]) ? titles.get(hit[1]) : str(d.name);
276
+ const name = fit(full, LIMITS.name, LIMITS.nameWords);
277
+ const age = ageWord(d.opened_at, ctx.now || 0);
278
+ const facts = [];
279
+ const add = (t) => { const v = fit(t, LIMITS.factLen); if (v && facts.indexOf(v) < 0 && facts.length < LIMITS.facts) facts.push(v); };
280
+ if (full !== name) add(full);
281
+ (d.facts || []).forEach(add);
282
+ if (age) add('since ' + age);
283
+ const ref = d.ref || {};
284
+ const card = C && C.actsCard ? C.actsCard({ key: d.id, kind: ref.type, id: ref.id, who: full, title: full, building: d.building, href: d.href }) : null;
285
+ return Object.assign(actedRow(d.acts || [], card || { href: null }, ctx), {
286
+ key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
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,
290
+ });
291
+ }
292
+ // The desk rows of one floor: those of the desk its PLAN entry names.
293
+ function deskRows(key, floor, docket, ctx) {
294
+ const plan = I && I.PLAN[key];
295
+ const spec = plan && plan.floors[floor];
296
+ const bind = (spec && spec[2]) || {};
297
+ if (!bind.desk || !docket || !Array.isArray(docket.desks)) return [];
298
+ // a floor may hold more than one desk (Rules in force: the settings and the modules)
299
+ const keys = [].concat(bind.desk);
300
+ return docket.desks.filter((x) => x && keys.indexOf(x.key) >= 0 && x.status === 'live' && Array.isArray(x.rows))
301
+ .reduce((all, x) => all.concat(x.rows.filter((r) => r && r.building === key)), []);
302
+ }
182
303
  function rowsFor(key, floor, docket, ctx) {
183
304
  const c = Object.assign({ docket: docket }, ctx || {});
184
305
  const done = c.done || {};
185
- return floorMatters(key, floor, docket).filter((m) => !done[str(m.id)]).map((m) => rowOf(m, c))
306
+ const desked = deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length);
307
+ // a desk row that stands in for a source (the core update's check-then-take)
308
+ // replaces that source's matter rows on this floor
309
+ const over = desked.map((r) => r.over).filter(Boolean);
310
+ return floorMatters(key, floor, docket).filter((m) => !done[str(m.id)] && over.indexOf(m.source) < 0).map((m) => rowOf(m, c))
311
+ .concat(desked)
186
312
  .sort((a, b) => (b.needs - a.needs) || ((Date.parse(a.opened || 0) || 0) - (Date.parse(b.opened || 0) || 0)));
187
313
  }
188
314
 
@@ -199,19 +325,37 @@
199
325
  const floor = Math.max(0, Math.min(Number(o.floor) || 0, plan.floors.length - 1));
200
326
  const spec = plan.floors[floor];
201
327
  const fl = I.floorFor(spec, { key: o.key, docket: docket, goals: o.goals || [], now: o.now || 0 });
202
- const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done };
203
- const rows = docket ? rowsFor(o.key, floor, docket, ctx) : [];
328
+ const ctx = { room: o.room, constitution: o.constitution, now: o.now || 0, viewerId: o.viewerId, done: o.done, goals: o.goals || [] };
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
+ }
204
342
  const bind = spec[2] || {};
205
343
  const src = bind.source ? bind.source : bind.floor === 'planning.ideas' ? 'idea' : floor === 0 ? 'board' : null;
206
344
  const needs = rows.filter((r) => r.needs);
207
345
  const noun = NOUNS[src] || 'open';
346
+ // rows nobody waits on: 'moving' for matters, the desk's own noun for desk rows
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';
350
+ const counted = rows.filter((r) => !r.tool);
208
351
  const oldestOf = (list) => list.reduce((a, r) => (r.opened && (!a || Date.parse(r.opened) < Date.parse(a)) ? r.opened : a), null);
209
352
  const cleared = !!o.cleared && rows.length === 0;
210
353
  const quiet = rows.length === 0;
211
354
  let status, hot = false;
212
355
  if (!docket) status = 'the docket could not be read';
213
356
  else if (needs.length) { hot = true; const age = ageWord(oldestOf(needs), o.now); status = needs.length + ' ' + noun + (age ? ' · oldest ' + age : ''); }
214
- else if (rows.length) { const age = ageWord(oldestOf(rows), o.now); status = rows.length + ' moving' + (age ? ' · oldest ' + age : ''); }
357
+ else if (counted.length) { const age = ageWord(oldestOf(counted), o.now); status = counted.length + ' ' + moving + (age ? ' · oldest ' + age : ''); }
358
+ else if (rows.length) status = fl.line;
215
359
  else if (cleared) status = 'floor clear';
216
360
  else status = 'nothing needs you · ' + fl.line;
217
361
  const history = docket && Array.isArray(docket.history) ? docket.history.filter((h) => h && h.building === o.key) : [];
@@ -224,7 +368,13 @@
224
368
  const words = str(l).replace(/\s+/g, ' ').trim().split(' ').slice(0, LIMITS.tileWords).join(' ').replace(/[,.;:·-]+$/, '');
225
369
  tiles.push({ key: 't' + tiles.length, v: fit(val, LIMITS.tileValue), l: fit(words, LIMITS.tileLabel) });
226
370
  };
227
- if (rows.length) { tile(needs.length, noun); tile(rows.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); }
228
378
  else if (fl.count) tile(fl.count, str(fl.line).replace(/^[$]?[0-9][0-9,.%]*\s*/, '') || 'on this floor');
229
379
  tile(history.length, 'on record');
230
380
  const record = (o.mine || []).map((t, i) => ({ key: 'm' + i, text: fit(t + ' · now', LIMITS.recordLen), mine: true }))
@@ -234,13 +384,50 @@
234
384
  key: o.key + ':' + floor, building: o.key, floor: floor, name: spec[0], sym: o.sym || { d: '', f: '' },
235
385
  status: fit(status, LIMITS.status), hot: hot,
236
386
  tiles: tiles.slice(0, LIMITS.tiles), rows: rows,
237
- quiet: quiet, cleared: cleared,
387
+ quiet: quiet, cleared: cleared, save: save,
238
388
  clearLine: fit(cleared ? (CLEAR[src] || 'Floor clear') : 'Nothing needs you here', LIMITS.clearLine),
239
389
  clearSub: fit(cleared ? 'lit up · ' + (o.today || 0) + ' decided today' : fl.line, LIMITS.clearSub),
240
390
  record: record,
241
391
  };
242
392
  }
243
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
+
244
431
  // ---------- the rule as a checker ----------
245
432
  function breaks(desk) {
246
433
  const out = [];
@@ -263,7 +450,14 @@
263
450
  if (r.facts.length > LIMITS.facts || r.facts.some((f) => !oneLine(f) || f.length > LIMITS.factLen)) bad('row facts', r.facts);
264
451
  if (r.chips && (r.chips.options.length > LIMITS.chips || r.chips.options.some((c) => c.label.length > LIMITS.chip))) bad('chips', r.chips.options.map((c) => c.label));
265
452
  if (r.actions.some((a) => a.ask) && !r.chips) bad('a reason without chips', r.name);
453
+ Object.keys(r.choices || {}).forEach((k) => {
454
+ const c = r.choices[k];
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));
456
+ });
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);
266
459
  });
460
+ if (desk.save && (!oneLine(desk.save.line) || desk.save.line.length > LIMITS.saveLine)) bad('save line', desk.save.line);
267
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));
268
462
  return out;
269
463
  }
@@ -277,8 +471,8 @@
277
471
  }
278
472
 
279
473
  const api = {
280
- LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
281
- fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips,
474
+ LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
475
+ fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
282
476
  };
283
477
  root.GovDesk = api;
284
478
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -112,6 +112,8 @@
112
112
  const PLAN_SYMBOLS = {
113
113
  'Rules in force': 'gavel', 'New rules': 'wrench', 'Security reports': 'shield', 'Removals': 'removal', 'Grade appeals': 'scales',
114
114
  'Goals': 'flag', 'Promises': 'seal', 'Spending matters': 'coins',
115
+ // the Desks board's name for the blockers floor (task 1004560)
116
+ 'Waiting on you': 'hourglass',
115
117
  };
116
118
  function floorSymbol(name) {
117
119
  return PROTO_SYMBOLS[name] || PLAN_SYMBOLS[name] || 'crate';
@@ -331,42 +333,43 @@
331
333
  rules: {
332
334
  holds: 'Standing rules. Each one settles a kind of matter so the council never votes on it again.', rule: 'majority', cap: 8,
333
335
  growth: 'When the hall fills, the council can open an annex for one area, such as money rules.', annex: 'room for an annex, such as money rules',
334
- floors: [['Rules in force', 'the rules settling matters now', { floor: 'rules.in_force', reading: 'rules' }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
336
+ floors: [['Rules in force', 'the rules settling matters now', { floor: 'rules.in_force', reading: 'rules', desk: ['rules.settings', 'rules.modules'] }], ['New rules', 'a rule put to the council', { source: 'standing_rules' }]],
335
337
  },
336
338
  disputes: {
337
339
  holds: 'Grade appeals, removal requests and security reports. Every ruling becomes precedent for the next one.', rule: 'decide', cap: 5,
338
340
  growth: 'Busy weeks go to a panel of three once a second room is built.', annex: 'room for a second room, a panel of three',
339
- floors: [['Security reports', 'flaws found in our own walls', { source: 'redteam_reports' }], ['Removals', 'work proposed for removal', { source: 'removal_proposals' }], ['Grade appeals', 'a grade someone asks to change', { source: 'grade_appeals' }]],
341
+ floors: [['Security reports', 'flaws found in our own walls', { source: 'redteam_reports', desk: 'disputes.confirmed' }], ['Removals', 'work proposed for removal', { source: 'removal_proposals' }], ['Grade appeals', 'a grade someone asks to change', { source: 'grade_appeals' }]],
340
342
  },
341
343
  people: {
342
344
  holds: 'Who belongs, the rank each person holds, and who sits in which seat.', rule: 'consent', cap: 8,
343
345
  growth: 'A new floor opens whenever the charter creates a new seat.', annex: 'room for an annex of seats',
344
- floors: [['The council', 'who sits, and for how long', { floor: 'people.council' }], ['Testers', 'who signs off finished work', { floor: 'people.testers' }], ['Builders', 'who builds here', { reading: 'people' }], ['Newcomers', 'builders on newcomer tasks', { floor: 'people.newcomers' }], ['Waiting to join', 'asked to join the project', { source: 'access_requests' }], ['Ranks', 'the ranks, and who holds each', { floor: 'people.ranks' }], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
346
+ floors: [['The council', 'who sits, and for how long', { floor: 'people.council' }], ['Testers', 'who signs off finished work', { floor: 'people.testers' }], ['Builders', 'who builds here', { reading: 'people', desk: 'people.builders' }], ['Newcomers', 'builders on newcomer tasks', { floor: 'people.newcomers' }], ['Waiting to join', 'asked to join the project', { source: 'access_requests', desk: 'people.invite' }], ['Ranks', 'the ranks, and who holds each', { floor: 'people.ranks', desk: 'people.ranks' }], ['Goal teams', 'asked to join a goal team', { source: 'goal_join_requests' }]],
345
347
  },
346
348
  planning: {
347
349
  holds: 'Goals, and the promises that say when each one is done. Kept promises are what the project owes its users.', rule: 'majority', cap: 9,
348
350
  growth: 'When it fills, the next new goal opens a second planning office.', annex: 'room for a second planning office',
349
- floors: [['Goals', 'the open goals', { floor: 'planning.goals', goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Ideas to ratify', 'full ideas waiting for a vote', { floor: 'planning.ideas' }], ['Priorities', 'the order goals are worked in', { floor: 'planning.priorities' }], ['Versions', 'what each version holds', { floor: 'planning.versions' }], ['Waiting on a decision', 'blockers holding work', { source: 'blockers' }]],
351
+ // the three desks first, as on the Desks board (task 1004560)
352
+ floors: [['Priorities', 'the order goals are worked in', { floor: 'planning.priorities', desk: 'planning.order' }], ['Ideas to ratify', 'ideas waiting to be ratified', { floor: 'planning.ideas', desk: 'planning.ratify' }], ['Waiting on you', 'blockers holding work', { source: 'blockers' }], ['Goals', 'the open goals', { floor: 'planning.goals', goals: true }], ['Promises', 'what the goals say they deliver', { reading: 'promises' }], ['Versions', 'what each version holds', { floor: 'planning.versions' }]],
350
353
  },
351
354
  automation: {
352
355
  holds: 'AI builders working on their own. You choose which goals they may build and how much they may spend, and you can stop them at any time.', rule: 'decide', cap: 6,
353
356
  growth: 'A new floor opens for each goal you let it build.', annex: 'room for an annex',
354
- floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence' }], ['Allowed goals', 'the goals it may build', { floor: 'automation.allowed_goals' }], ['Running now', 'builds in progress', { floor: 'automation.running' }], ['Spending limit', 'what it may spend', { floor: 'automation.spending_limit' }], ['Reports', 'what it did, told without asking', { floor: 'automation.reports' }]],
357
+ floors: [['The switch', 'unattended building, on or off', { source: 'autobongos_fence', desk: 'automation.switch' }], ['Allowed goals', 'the goals it may build', { floor: 'automation.allowed_goals', desk: 'automation.goals' }], ['Running now', 'builds in progress', { floor: 'automation.running', desk: 'automation.runners' }], ['Spending limit', 'what it may spend', { floor: 'automation.spending_limit' }], ['Reports', 'what it did, told without asking', { floor: 'automation.reports' }]],
355
358
  },
356
359
  review: {
357
360
  holds: 'Testing finished work and signing it off. Work does not count until someone who did not build it says it works.', rule: 'signoff', cap: 5,
358
361
  growth: 'When one queue of testers is not enough, an annex can take design reviews.', vacantGrowth: 'Nothing is built here until the charter says who holds the seat.', annex: 'room for an annex, such as design review',
359
- floors: [['Testers', 'who signs off finished work', { floor: 'review.testers' }], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat', held: 'Being tested' }], ['Signed off', 'work a tester has signed off', { floor: 'review.signed_off' }]],
362
+ floors: [['Testers', 'who signs off finished work', { floor: 'review.testers' }], ['Waiting outside', 'finished work waiting for a tester', { source: 'criteria_uat', held: 'Being tested' }], ['Signed off', 'work a tester has signed off', { floor: 'review.signed_off', desk: 'review.signed' }]],
360
363
  },
361
364
  release: {
362
365
  holds: 'Putting finished work live, and taking updates to Bongos itself.', rule: 'decide', cap: 7,
363
366
  growth: 'Room for a second gate if releases are ever split by area.', annex: 'room for a second gate',
364
- floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live', { floor: 'release.held_back' }], ['Core update', 'a new version of Bongos', { source: 'core_update' }], ['Live this month', 'what went live', { floor: 'release.live_month' }], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
367
+ floors: [['Overrides', 'past a failed check or grade', { source: 'override_requests' }], ['Modules', 'listed modules, and their re-checks', { source: 'module_rechecks' }], ['Held back', 'work held from going live', { floor: 'release.held_back' }], ['Core update', 'a new version of Bongos', { source: 'core_update', desk: 'release.core' }], ['Live this month', 'what went live', { floor: 'release.live_month' }], ['Ready to go', 'signed off, waiting to go live', { source: 'gate_approvals' }]],
365
368
  },
366
369
  treasury: {
367
370
  holds: 'The budget, and who gets paid for what.', rule: 'majority', cap: 7,
368
371
  growth: 'Each new spending line takes a floor.', annex: 'room for an annex of spending lines',
369
- floors: [['Builder rewards', 'paid for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting' }], ['Reserve', 'what is left this month', { reading: 'money' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
372
+ floors: [['Builder rewards', 'paid for shipped work', { floor: 'treasury.rewards' }], ['AI grading', 'the reviewer that grades every ship', { floor: 'treasury.grading' }], ['Hosting', 'servers and storage', { floor: 'treasury.hosting', desk: 'treasury.backup' }], ['Reserve', 'what is left this month', { reading: 'money', desk: 'treasury.budgets' }], ['Spending matters', 'spending put to a vote', { source: 'treasury_spending' }]],
370
373
  },
371
374
  });
372
375
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];