@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
@@ -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';
@@ -346,7 +348,8 @@
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,
@@ -356,7 +359,7 @@
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,
@@ -366,7 +369,7 @@
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', desk: 'treasury.backup' }], ['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'];
@@ -314,6 +314,18 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
314
314
  .desk-chip:hover { filter: brightness(1.08); }
315
315
  .desk-chip[disabled] { opacity: 0.55; cursor: progress; }
316
316
  .desk-wait { font-size: 13px; color: var(--ink-faint); border: 1px dashed var(--street-rule-strong); border-radius: 999px; padding: 6px 12px; white-space: nowrap; }
317
+ /* the Planning desks (task 1004560): a goal's place in the order, its promise
318
+ pips (or a vote's seats), the two arrows, and the save bar under a moved order */
319
+ .desk-pos { width: 22px; flex: none; font-size: 13px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
320
+ .desk-pips { display: flex; gap: 4px; flex: none; }
321
+ .desk-pip { width: 12px; height: 12px; border-radius: 3px; box-sizing: border-box; border: 1px solid var(--street-rule-strong); }
322
+ .desk-pip--on { background: var(--ink); border-color: var(--ink); }
323
+ .desk-arrow { width: 34px; height: 34px; flex: none; border-radius: 999px; border: 1px solid var(--street-rule-strong); background: none; color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
324
+ .desk-arrow svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
325
+ .desk-arrow[disabled] { opacity: 0.55; cursor: progress; }
326
+ .desk-arrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
327
+ .desk-save { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; padding: 16px 0 0; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
328
+ .desk-save__line { font-size: 13.5px; color: var(--ink-soft); margin-right: auto; }
317
329
  .desk-facts, .desk-choices { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 10px 0 2px 52px; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
318
330
  .desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
319
331
  .desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
@@ -373,6 +385,9 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
373
385
  .inside-gov__row { flex-wrap: wrap; row-gap: 4px; }
374
386
  .desk-row__main { flex-wrap: wrap; row-gap: 8px; }
375
387
  .desk-name { flex-basis: calc(100% - 52px); }
388
+ .desk-pos ~ .desk-name { flex-basis: calc(100% - 88px); }
389
+ .desk-pos ~ .desk-pips { margin-left: 88px; }
390
+ .desk-pips ~ .desk-acts { margin-left: auto; }
376
391
  .desk-acts { margin-left: 52px; }
377
392
  .desk-facts, .desk-choices { padding-left: 0; }
378
393
  .desk-tile { min-width: 0; padding: 10px 14px; }
@@ -82,7 +82,8 @@
82
82
  var view = { open: null, opener: null, model: null, scale: 1, mode: null, inside: null, insideModel: null };
83
83
  // the desk's own state: rows you acted on this visit, what you did per floor,
84
84
  // the floors you cleared, the row whose facts or chips are open
85
- var desk = { done: {}, mine: {}, cleared: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
85
+ // order: the Priorities desk's order as the viewer has moved it, per floor (task 1004560)
86
+ var desk = { done: {}, mine: {}, cleared: {}, order: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
86
87
  var TODAY_KEY = 'gov-desk-today';
87
88
  var today = { day: '', n: 0 };
88
89
 
@@ -553,7 +554,7 @@
553
554
  var D = window.GovDesk, st = view.inside;
554
555
  if (!D || !st) return;
555
556
  var k = st.key + ':' + bv.selFloor;
556
- var dm = D.deskModel({ key: st.key, floor: bv.selFloor, docket: data.docket, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), now: Date.now(), room: data.room, constitution: data.constitution, done: desk.done, mine: desk.mine[k] || [], cleared: !!desk.cleared[k], today: today.n, sym: bv.sym });
557
+ var dm = D.deskModel({ key: st.key, floor: bv.selFloor, docket: data.docket, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), now: Date.now(), room: data.room, constitution: data.constitution, done: desk.done, mine: desk.mine[k] || [], cleared: !!desk.cleared[k], today: today.n, sym: bv.sym, order: desk.order[k] || null });
557
558
  desk.model = dm;
558
559
  el.className = 'inside-open desk' + (dm.quiet ? ' desk--quiet' : '');
559
560
  el.setAttribute('data-desk', dm.key);
@@ -595,6 +596,14 @@
595
596
  dm.rows.forEach(function (r) { list.appendChild(deskRow(r)); });
596
597
  el.appendChild(list);
597
598
  }
599
+ // a changed order: what saving does, then undo and save (task 1004560)
600
+ if (dm.save) {
601
+ var bar = node('div', 'desk-save');
602
+ bar.appendChild(node('span', 'desk-save__line', dm.save.line));
603
+ bar.appendChild(chipBtn('desk-chip--quiet', 'undo', function () { desk.order[k] = null; draw(); }));
604
+ bar.appendChild(chipBtn('desk-chip--primary', dm.save.label, function () { saveOrder(dm, k); }));
605
+ el.appendChild(bar);
606
+ }
598
607
  // the record line: what you just did here, then what this building closed
599
608
  var rec = node('div', 'desk-record');
600
609
  rec.appendChild(node('span', 'desk-record__label', 'record'));
@@ -606,6 +615,7 @@
606
615
  var row = node('div', 'desk-row' + (r.needs ? ' desk-row--needs' : ''));
607
616
  row.setAttribute('data-row', r.key);
608
617
  var main = node('div', 'desk-row__main');
618
+ if (r.pos) main.appendChild(node('span', 'desk-pos', String(r.pos)));
609
619
  main.appendChild(node('span', 'desk-ini', r.ini));
610
620
  var name = node('button', 'desk-name');
611
621
  name.setAttribute('type', 'button');
@@ -615,10 +625,33 @@
615
625
  name.appendChild(node('span', 'desk-name__f', r.fact));
616
626
  name.addEventListener('click', function () { desk.open = desk.open === r.key ? null : r.key; desk.ask = null; draw(); focusRow(r.key, 'name'); });
617
627
  main.appendChild(name);
628
+ // promises kept of total, or a vote's seats: one pip each
629
+ if (r.pips && r.pips.length) {
630
+ var pips = node('span', 'desk-pips');
631
+ pips.setAttribute('aria-hidden', 'true');
632
+ r.pips.forEach(function (p) { pips.appendChild(node('span', 'desk-pip' + (p.on ? ' desk-pip--on' : ''))); });
633
+ main.appendChild(pips);
634
+ }
618
635
  var acts = node('span', 'desk-acts');
619
636
  if (r.wait) acts.appendChild(node('span', 'desk-wait', r.wait));
637
+ if (r.order) {
638
+ // the order's two arrows: the move is the viewer's until saved
639
+ [['up', -1, 'M6 15l6-6 6 6'], ['down', 1, 'M6 9l6 6 6-6']].forEach(function (m) {
640
+ if (!r.actions.some(function (a) { return a.key === m[0]; })) return;
641
+ var b = node('button', 'desk-arrow');
642
+ b.setAttribute('type', 'button');
643
+ b.setAttribute('aria-label', 'move ' + r.name + ' ' + m[0]);
644
+ // the top goal cannot go up, nor the last down
645
+ if (desk.busy || (m[1] < 0 && r.top) || (m[1] > 0 && r.bottom)) b.setAttribute('disabled', '');
646
+ b.appendChild(icon(m[2], null, 14));
647
+ b.addEventListener('click', function () { moveRow(r, m[1]); });
648
+ acts.appendChild(b);
649
+ });
650
+ }
620
651
  // the primary button sits rightmost, as on the board
621
- r.actions.slice().reverse().forEach(function (a) {
652
+ // a row whose chips stand open is decided by its chip alone: no button for it
653
+ if (!r.order) r.actions.slice().reverse().forEach(function (a) {
654
+ if (r.show && a.key === r.show) return;
622
655
  acts.appendChild(chipBtn('desk-chip--' + a.tone, a.label, function () {
623
656
  if (a.go) { window.location.href = a.go; return; }
624
657
  if (a.ask) { var at = r.key + '|' + a.key; desk.ask = desk.ask === at ? null : at; desk.open = null; draw(); focusRow(r.key, 'chip'); return; }
@@ -633,6 +666,8 @@
633
666
  row.appendChild(facts);
634
667
  }
635
668
  var asked = desk.ask && desk.ask.indexOf(r.key + '|') === 0 ? (r.choices || {})[desk.ask.slice(r.key.length + 1)] : null;
669
+ // a blocker's options stand open: deciding it is picking one
670
+ if (!asked && r.show) asked = (r.choices || {})[r.show] || null;
636
671
  if (asked) {
637
672
  var chips = node('div', 'desk-choices');
638
673
  chips.appendChild(node('span', 'desk-choices__ask', asked.ask));
@@ -653,6 +688,35 @@
653
688
  }
654
689
  return row;
655
690
  }
691
+ // The Priorities desk (task 1004560): a move is kept in this visit until saved.
692
+ function moveRow(r, by) {
693
+ var D = window.GovDesk, st = view.inside;
694
+ if (!D || !st || !desk.model) return;
695
+ var k = st.key + ':' + (view.insideModel ? view.insideModel.selFloor : 0);
696
+ var now = desk.model.rows.filter(function (x) { return x.order; }).map(function (x) { return x.key; });
697
+ desk.order[k] = D.moved(now, r.key, by);
698
+ draw();
699
+ focusRow(r.key, 'arrow');
700
+ }
701
+ // Save: the order goes to POST /goals/reorder, or is filed for the council;
702
+ // the server's own checks decide either way.
703
+ function saveOrder(dm, k) {
704
+ var C = window.GovMatterCard;
705
+ var req = C && C.orderRequest ? C.orderRequest(dm.save) : null;
706
+ if (!req || desk.busy) return Promise.resolve(false);
707
+ desk.busy = true;
708
+ draw();
709
+ return send(req).then(function (out) {
710
+ desk.busy = false;
711
+ if (!out.ok) { say(C.refusal(out.status, out.body)); draw(); return false; }
712
+ desk.order[k] = null;
713
+ desk.mine[k] = [dm.save.council ? 'order sent to council' : 'order set'].concat(desk.mine[k] || []);
714
+ bumpToday();
715
+ say(dm.save.said);
716
+ draw();
717
+ return load().then(function () { return true; });
718
+ });
719
+ }
656
720
  function deskRowsEl() {
657
721
  var st = view.insideModel;
658
722
  var on = $(st && st.flipA ? 'insideOpenA' : 'insideOpenB');
@@ -667,6 +731,7 @@
667
731
  if (rows[i].getAttribute('data-row') !== key) continue;
668
732
  var main = rows[i].children[0];
669
733
  var target = what === 'chip' && rows[i].children[1] ? rows[i].children[1].children[1] : main && main.children[1];
734
+ if (what === 'arrow' && main) target = main.querySelector ? main.querySelector('.desk-arrow') : target;
670
735
  if (target && target.focus) target.focus();
671
736
  return;
672
737
  }
@@ -685,6 +750,8 @@
685
750
  switch_on: 'switched on', switch_off: 'switched off', allow: 'allowed', disallow: 'disallowed', first: 'put first',
686
751
  pause: 'paused', resume: 'resumed', tier: 'new tier', deactivate: 'deactivated', give: 'gave', take: 'took back',
687
752
  check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
753
+ decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
754
+ budget: 'set the limit of', unsign: 'undid',
688
755
  };
689
756
  // One act from a row: checked, sent through the card's own request (the
690
757
  // server's permission checks decide), then the row clears, the floor counts
@@ -699,7 +766,9 @@
699
766
  var req = C.request(r.card, key, input);
700
767
  var settle = function (body) {
701
768
  var a = C.answer(r.card, key, body);
702
- desk.mine[k] = [DONE_WORDS[key] + ' ' + r.name].concat(desk.mine[k] || []);
769
+ // an act at a building a sitting decides went to it as a matter (task 1004561)
770
+ var sent = r.card.subject && r.card.subject.council && req && /\/government\/board\/matters$/.test(req.url);
771
+ desk.mine[k] = [(sent ? 'sent to council:' : DONE_WORDS[key]) + ' ' + r.name].concat(desk.mine[k] || []);
703
772
  desk.done[r.matter] = true;
704
773
  desk.open = null; desk.ask = null;
705
774
  if (desk.model && !desk.model.rows.some(function (x) { return x.matter !== r.matter; })) desk.cleared[k] = true;
@@ -681,6 +681,130 @@
681
681
  ".desk-rows"
682
682
  ]
683
683
  }
684
+ },
685
+ "desk-planning-priorities": {
686
+ "auth": true,
687
+ "_": "Task 1004560: Planning, Priorities. The open goals in order with promise pips and two arrows; one goal moved down, so the save bar says the new order goes to the council and how many yes it needs, with undo.",
688
+ "url": "/builders/street#building=planning",
689
+ "actions": [
690
+ [
691
+ "wait",
692
+ 900
693
+ ],
694
+ [
695
+ "clickIfVisible",
696
+ "[data-craft-close]"
697
+ ],
698
+ [
699
+ "wait",
700
+ 300
701
+ ],
702
+ [
703
+ "click",
704
+ "[data-floor=\"0\"]"
705
+ ],
706
+ [
707
+ "wait",
708
+ 600
709
+ ],
710
+ [
711
+ "click",
712
+ ".desk-arrow[aria-label$=\" down\"]"
713
+ ],
714
+ [
715
+ "wait",
716
+ 400
717
+ ]
718
+ ],
719
+ "expect": {
720
+ "visible": [
721
+ ".desk-rows",
722
+ ".desk-pips",
723
+ ".desk-save"
724
+ ]
725
+ }
726
+ },
727
+ "desk-planning-ideas": {
728
+ "auth": true,
729
+ "_": "Task 1004560: Planning, Ideas to ratify. A Full Idea in its vote with seat pips, a promise to ratify in one tap, and a carried Full Idea made a goal from a version chip.",
730
+ "url": "/builders/street#building=planning",
731
+ "actions": [
732
+ [
733
+ "wait",
734
+ 900
735
+ ],
736
+ [
737
+ "clickIfVisible",
738
+ "[data-craft-close]"
739
+ ],
740
+ [
741
+ "wait",
742
+ 300
743
+ ],
744
+ [
745
+ "click",
746
+ "[data-floor=\"1\"]"
747
+ ],
748
+ [
749
+ "wait",
750
+ 600
751
+ ],
752
+ [
753
+ "focus",
754
+ ".desk-rows .desk-row:last-child .desk-name"
755
+ ],
756
+ [
757
+ "wait",
758
+ 300
759
+ ]
760
+ ],
761
+ "expect": {
762
+ "visible": [
763
+ ".desk-rows"
764
+ ]
765
+ }
766
+ },
767
+ "desk-planning-blockers": {
768
+ "auth": true,
769
+ "_": "Task 1004560: Planning, Waiting on you. Each blocker decided by one of its options as chips standing open; go ahead, not now, need more when it lists none.",
770
+ "url": "/builders/street#building=planning",
771
+ "actions": [
772
+ [
773
+ "wait",
774
+ 900
775
+ ],
776
+ [
777
+ "clickIfVisible",
778
+ "[data-craft-close]"
779
+ ],
780
+ [
781
+ "wait",
782
+ 300
783
+ ],
784
+ [
785
+ "click",
786
+ "[data-floor=\"2\"]"
787
+ ],
788
+ [
789
+ "wait",
790
+ 600
791
+ ],
792
+ [
793
+ "focus",
794
+ ".desk-rows .desk-row:last-child .desk-name"
795
+ ],
796
+ [
797
+ "wait",
798
+ 300
799
+ ]
800
+ ],
801
+ "expect": {
802
+ "visible": [
803
+ ".desk-rows",
804
+ ".desk-choices",
805
+ ".desk-status--hot"
806
+ ]
807
+ }
684
808
  }
685
809
  },
686
810
  "reducedMotion": {
@@ -19,6 +19,7 @@
19
19
 
20
20
  const api = require('../../src/module-api');
21
21
  const blockers = require('./blockers');
22
+ const ratify = require('./ratify');
22
23
 
23
24
  const POINT = 'governor.docket';
24
25
  const DOCKET_BLOCKER_LIMIT = 50;
@@ -41,9 +42,96 @@ function blockerDocketMatter(b, mayResolve) {
41
42
  tally: null,
42
43
  href: `/blockers#blocker-${b.id}`,
43
44
  ref: { type: 'blocker', id: b.id },
45
+ // the floor desk's one act (task 1004560): decide, by one of its options
46
+ ...(mayResolve ? { acts: [{ key: 'decide', options: blockerOptions(b.body_md) }] } : {}),
44
47
  };
45
48
  }
46
49
 
50
+ // A blocker's options as one-tap chips (task 1004560). Its own, when its body
51
+ // lists them as "Option A: …" lines (two to five of them); otherwise the three
52
+ // answers every blocker takes. The chosen key becomes the resolution note, so
53
+ // a key is the option's own words (at most 64 characters) and the label its
54
+ // first words. Pure.
55
+ const DEFAULT_CHOICES = Object.freeze(['go ahead', 'not now', 'need more']);
56
+ const OPTION_LINE = /^\s*(?:[-*]|\d+[.)])?\s*(?:\*\*)?option\s+[A-Za-z0-9]{1,2}(?:\*\*)?\s*[:.)—-]+\s*(?:\*\*)?(.+)$/i;
57
+ function plainWords(s) { return String(s).replace(/[*_`[\]]+/g, '').replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim(); }
58
+ function shortLabel(s) {
59
+ let out = '';
60
+ for (const w of s.split(' ')) { const next = out ? out + ' ' + w : w; if (next.length > 16) break; out = next; }
61
+ return out || s.slice(0, 16);
62
+ }
63
+ function blockerOptions(bodyMd) {
64
+ const found = [];
65
+ for (const line of String(bodyMd || '').split(/\r?\n/)) {
66
+ const m = OPTION_LINE.exec(line);
67
+ if (!m) continue;
68
+ const text = plainWords(m[1]).split(/\s[—–-]\s|[.;:]\s/)[0].trim();
69
+ if (text && !found.some((o) => o.key === text.slice(0, 64))) found.push({ key: text.slice(0, 64), label: shortLabel(text) });
70
+ }
71
+ if (found.length >= 2 && found.length <= 5) return found;
72
+ return DEFAULT_CHOICES.map((t) => ({ key: t, label: t }));
73
+ }
74
+
75
+ // The Ideas-to-ratify desk (task 1004560): the ideas a ratifier may take from
76
+ // the floor, under idea.ratify (both ratify routes' atom). A criterion proposal
77
+ // ratifies in one tap (POST /inbox/:id/ratify). A Full Idea the board has
78
+ // carried becomes a goal (POST /inbox/:id/ratify-goal) with its target version
79
+ // as a chip — only when its answers compose a goal on their own; one that needs
80
+ // the ratifier to write its criterion opens its page instead. A Full Idea still
81
+ // in its sitting is not here: it is the board's vote row (ADR 0175 §8). Pure.
82
+ function ratifyDeskRows(ideas, versions, passed) {
83
+ const chips = (versions || []).slice(0, 5).map((v) => ({ key: String(v.id), label: String(v.id) }));
84
+ const out = [];
85
+ for (const i of ideas || []) {
86
+ if (!i || i.status !== 'open') continue;
87
+ const href = `/idea/${i.id}`;
88
+ if (i.kind === 'criterion-proposal') {
89
+ let p = null;
90
+ try { p = ratify.parseProposalBody(i.body_md); } catch (_) { p = null; }
91
+ if (!p || !p.target_version) continue;
92
+ out.push({
93
+ id: `p${i.id}`, name: String(i.title || 'a promise'), ref: { type: 'criterion_proposal', id: i.id }, href, opened_at: i.created_at,
94
+ fact: `a promise for ${p.target_version}`, facts: [String(p.criterion_md || '').slice(0, 80)],
95
+ acts: [{ key: 'ratify' }, { key: 'open' }],
96
+ });
97
+ continue;
98
+ }
99
+ if (i.grade !== 'full' || !passed.has(String(i.id))) continue;
100
+ const composed = ratify.composeGoalFromFullIdea({ ideaId: i.id, templateKey: i.template_key, fieldValues: i.field_values });
101
+ const fits = composed && composed.ok && chips.length > 0;
102
+ out.push({
103
+ id: `g${i.id}`, name: String(i.title || 'a full idea'), ref: { type: 'full_idea', id: i.id }, href, opened_at: i.created_at,
104
+ fact: 'the board carried it', facts: [fits ? 'becomes a goal' : 'needs its page to finish'],
105
+ acts: fits ? [{ key: 'ratify_goal', options: chips }, { key: 'open' }] : [{ key: 'open' }],
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+ // The desk reads the oldest 30 open proposals and Full Ideas (older first, so
111
+ // nothing waits forever behind newer ones); which Full Ideas the board carried
112
+ // is one read for all of them (government.boardPassedIdeaIds).
113
+ const RATIFY_IDEAS_LIMIT = 30;
114
+ async function readRatifyDesk() {
115
+ const { rows: ideas } = await api.pool.query(
116
+ `SELECT i.id, i.title, i.kind, i.status, i.body_md, i.created_at,
117
+ COALESCE(g.grade, 'quick') AS grade, g.template_key, g.field_values
118
+ FROM idea_inbox i LEFT JOIN ideas_grades g ON g.idea_id = i.id
119
+ WHERE i.status = 'open' AND (i.kind = 'criterion-proposal' OR g.grade = 'full')
120
+ ORDER BY i.created_at, i.id LIMIT $1`,
121
+ [RATIFY_IDEAS_LIMIT],
122
+ );
123
+ const government = api.resolveOptional && api.resolveOptional('government');
124
+ const passed = new Set();
125
+ const full = ideas.filter((i) => i.grade === 'full').map((i) => i.id);
126
+ if (full.length && government && typeof government.boardPassedIdeaIds === 'function') {
127
+ (await government.boardPassedIdeaIds(full)).forEach((id) => passed.add(String(id)));
128
+ }
129
+ const { rows: versions } = passed.size
130
+ ? await api.pool.query(`SELECT id, name FROM versions WHERE status IN ('building', 'planning') ORDER BY (status = 'building') DESC, id LIMIT 5`)
131
+ : { rows: [] };
132
+ return ratifyDeskRows(ideas, versions, passed);
133
+ }
134
+
47
135
  // The rules-at-work reading from this month's routed filings. Pure.
48
136
  function rulesReading(settledThisMonth) {
49
137
  const n = Number(settledThisMonth) || 0;
@@ -114,6 +202,7 @@ function contributeIdeasToDocket() {
114
202
  return rows.map((b) => blockerDocketMatter(b, mayResolve));
115
203
  },
116
204
  });
205
+ api.contribute(POINT, { desk: 'planning.ratify', gate: { permission: 'idea.ratify' }, read: () => readRatifyDesk() });
117
206
  // A count, no rows: any builder may read it.
118
207
  api.contribute(POINT, {
119
208
  reading: 'rules',
@@ -127,6 +216,8 @@ function contributeIdeasToDocket() {
127
216
  module.exports = {
128
217
  contributeIdeasToDocket,
129
218
  blockerDocketMatter,
219
+ blockerOptions,
220
+ ratifyDeskRows,
130
221
  rulesReading,
131
222
  rulesFloor,
132
223
  rulesHistory,
@@ -181,6 +181,37 @@ function prioritiesFloor(goals, versionId) {
181
181
  return { count: '', line: 'the order goals are worked in', lines: [[top, versionId ? `version ${versionId}` : ''], [`${plural(mine.length, 'goal is', 'goals are')} in the order.`, '']], hot: false };
182
182
  }
183
183
 
184
+ // The Priorities desk (task 1004560): the building version's open goals in their
185
+ // worked order, each with its promises kept of total, for a viewer who may set
186
+ // the order — POST /goals/reorder's own atom (goal.reorder), or, where Planning
187
+ // is put to a sitting, the right to file the matter (board.item.open). Which of
188
+ // the two a save takes is the decision table's call, read by the screen from the
189
+ // docket's own buildings row; the route the save reaches still decides. Pure.
190
+ const ORDER_PIPS = 12;
191
+ function orderDeskRows(goals, criteria, versionId, mayOrder) {
192
+ if (!mayOrder || !versionId) return [];
193
+ const by = new Map();
194
+ for (const c of criteria || []) {
195
+ if (c.goal_id == null) continue;
196
+ const k = String(c.goal_id);
197
+ if (!by.has(k)) by.set(k, { kept: 0, total: 0 });
198
+ const e = by.get(k); e.total += 1; if (c.satisfied === true) e.kept += 1;
199
+ }
200
+ const mine = (goals || []).filter((g) => g && g.status === 'open' && g.version_id === versionId);
201
+ if (mine.length < 2) return []; // one goal has no order to set
202
+ return mine.map((g) => {
203
+ const e = by.get(String(g.id));
204
+ const of = e ? Math.min(e.total, ORDER_PIPS) : 0;
205
+ return {
206
+ id: g.id, name: String(g.title), ref: { type: 'goal', id: g.id }, set: String(versionId),
207
+ fact: e ? `${e.kept} of ${e.total} promises kept` : 'no promises yet',
208
+ facts: [`version ${versionId}`],
209
+ ...(of ? { pips: { on: Math.min(e.kept, of), of } } : {}),
210
+ acts: [{ key: 'up' }, { key: 'down' }],
211
+ };
212
+ });
213
+ }
214
+
184
215
  // Planning · Versions: what is building, and the last that shipped.
185
216
  function versionsFloor(versions, goals) {
186
217
  const all = versions || [];
@@ -210,6 +241,26 @@ function signedOffFloor(criteria, now) {
210
241
  };
211
242
  }
212
243
 
244
+ // The Review desk's signed-off rows (task 1004561): the newest promises signed
245
+ // off, each with one act, undo, for a viewer holding criterion.unsatisfy (POST
246
+ // /done-when/:id/unsatisfy's own atom). The reason is one tap. Pure.
247
+ const UNDO_REASONS = Object.freeze([
248
+ { key: 'broke', label: 'it broke again' },
249
+ { key: 'untested', label: 'not truly tested' },
250
+ { key: 'error', label: 'signed in error' },
251
+ ]);
252
+ const SIGNED_ROWS = 8;
253
+ function signedDeskRows(criteria) {
254
+ return (criteria || []).filter((c) => c && c.satisfied === true && c.checked_at)
255
+ .sort((a, b) => Date.parse(b.checked_at) - Date.parse(a.checked_at))
256
+ .slice(0, SIGNED_ROWS)
257
+ .map((c) => ({
258
+ id: c.id, name: String(c.criterion_md), fact: `signed off ${dayWord(c.checked_at)}`,
259
+ facts: c.goal_title ? [String(c.goal_title)] : [], ref: { type: 'signed_criterion', id: c.id },
260
+ acts: [{ key: 'unsign', options: UNDO_REASONS }],
261
+ }));
262
+ }
263
+
213
264
  // Release · Live this month: shipped work, newest first.
214
265
  function liveMonthFloor(shipped, now) {
215
266
  const month = (shipped || []).filter((t) => t.shipped_at && Date.parse(t.shipped_at) >= monthStart(now));
@@ -284,6 +335,15 @@ function contributeLifecycleFloors() {
284
335
  floor: 'planning.priorities', gate: any,
285
336
  read: async (_ctx, { once } = {}) => prioritiesFloor(await openGoals(once), await db.currentBuildingVersionId()),
286
337
  });
338
+ api.contribute(POINT, {
339
+ desk: 'planning.order', gate: any,
340
+ read: async (ctx, { once } = {}) => {
341
+ const may = ctx.has('goal.reorder') || ctx.has('board.item.open');
342
+ if (!may) return [];
343
+ const v = await db.currentBuildingVersionId();
344
+ return orderDeskRows(await openGoals(once), v ? await doneWhen.listCriteria(v) : [], v, may);
345
+ },
346
+ });
287
347
  api.contribute(POINT, {
288
348
  floor: 'planning.versions', gate: any,
289
349
  read: async () => versionsFloor(await db.listVersions(), await db.listGoals({})),
@@ -295,6 +355,13 @@ function contributeLifecycleFloors() {
295
355
  return signedOffFloor(v ? await doneWhen.listCriteria(v) : [], now);
296
356
  },
297
357
  });
358
+ api.contribute(POINT, {
359
+ desk: 'review.signed', gate: { permission: 'criterion.unsatisfy' },
360
+ read: async () => {
361
+ const v = await db.currentBuildingVersionId();
362
+ return signedDeskRows(v ? await doneWhen.listCriteria(v) : []);
363
+ },
364
+ });
298
365
  api.contribute(POINT, {
299
366
  floor: 'release.live_month', gate: any,
300
367
  read: async (_ctx, { now } = {}) => liveMonthFloor(await db.recentShippedTasks(SHIPPED_LIMIT, { days: 31 }), now),
@@ -398,8 +465,10 @@ module.exports = {
398
465
  promisesReading,
399
466
  goalsFloor,
400
467
  prioritiesFloor,
468
+ orderDeskRows,
401
469
  versionsFloor,
402
470
  signedOffFloor,
471
+ signedDeskRows,
403
472
  liveMonthFloor,
404
473
  heldBackFloor,
405
474
  newcomersFloor,
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/matter-acts.js — what a PASSED Review or Release matter
4
+ // carries out (task 1004561, goal 1000125). When a project's decision table puts
5
+ // Review or Release to a sitting, its passed matter acts here, after the close
6
+ // commits, through the SAME function the direct route calls:
7
+ // review satisfy_criterion → satisfyByOverride (POST /done-when/:id/satisfy)
8
+ // release approve_override → db.decideOverrideRequest (POST /override-requests/:id/decide)
9
+ // release approve_gate → approveGatePr (POST /gate-approvals/:pr/approve)
10
+ // The government module only checks the act's shape and calls in through the
11
+ // 'governor.matterAct' contribution point (a module never requires a sibling).
12
+ // Each act returns { done, reason? }: a refusal the route would give (already
13
+ // decided, not open, owner only) is a reason, never a throw.
14
+
15
+ const api = require('../../src/module-api');
16
+ const db = require('./db');
17
+ const doneWhen = require('./done-when');
18
+
19
+ const POINT = 'governor.matterAct';
20
+ const why = (area, ctx) => `A passed ${area} matter (matter ${ctx && ctx.matterId}) decided it.`;
21
+
22
+ // Required lazily: the route files build routers, and only their shared
23
+ // functions are needed here.
24
+ const routes = {
25
+ doneWhen: () => require('./routes/done-when'),
26
+ gate: () => require('./routes/gate-approvals'),
27
+ };
28
+
29
+ const ACTS = Object.freeze({
30
+ review: Object.freeze({
31
+ satisfy_criterion: async (action, ctx) => {
32
+ const c = await doneWhen.getCriterion(action.criterion_id);
33
+ if (!c) return { done: false, reason: 'criterion_not_found' };
34
+ if (c.satisfied === true) return { done: false, reason: 'already_satisfied' };
35
+ await routes.doneWhen().satisfyByOverride({ criterionId: action.criterion_id, signerId: ctx.decidedBy, reason: why('Review', ctx) });
36
+ return { done: true };
37
+ },
38
+ }),
39
+ release: Object.freeze({
40
+ approve_override: async (action, ctx) => {
41
+ try {
42
+ await db.decideOverrideRequest({ requestId: action.request_id, archonId: ctx.decidedBy, decision: 'approve', note: why('Release', ctx) });
43
+ return { done: true };
44
+ } catch (err) {
45
+ if (err && err.code === 'REQUEST_NOT_OPEN') return { done: false, reason: 'request_not_open' };
46
+ if (err && err.code === 'OVERRIDE_OWNER_ONLY') return { done: false, reason: 'override_owner_only' };
47
+ throw err;
48
+ }
49
+ },
50
+ approve_gate: async (action, ctx) => {
51
+ const out = await routes.gate().approveGatePr({ number: action.pr, login: `council, matter ${ctx && ctx.matterId}` });
52
+ return out.ok ? { done: true } : { done: false, reason: out.code };
53
+ },
54
+ }),
55
+ });
56
+
57
+ function contributeLifecycleMatterActs() {
58
+ for (const [area, acts] of Object.entries(ACTS)) {
59
+ for (const [type, act] of Object.entries(acts)) api.contribute(POINT, { area, type, act });
60
+ }
61
+ }
62
+
63
+ module.exports = { ACTS, contributeLifecycleMatterActs };