@bongos/core 1.21.166 → 1.21.168

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 (83) hide show
  1. package/.bongos-core.json +153 -73
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +306 -3
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +21 -20
  10. package/docs/copy-registry.json +34 -25
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/modules-contract.md +1 -0
  13. package/docs/page-inventory.json +3 -0
  14. package/docs/page-readings.json +445 -445
  15. package/modules/autonomy/governor-docket.js +10 -5
  16. package/modules/builder-settings/builder-needs.js +29 -3
  17. package/modules/copy-desk/guarded-lines.js +89 -0
  18. package/modules/copy-desk/guarded-lines.json +14 -0
  19. package/modules/copy-desk/module.json +2 -2
  20. package/modules/copy-desk/page-status.js +77 -7
  21. package/modules/copy-desk/pages.js +4 -0
  22. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  23. package/modules/copy-desk/routes/copy-desk.js +8 -0
  24. package/modules/copy-desk/stuck-tweaks.js +79 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  26. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  27. package/modules/government/asks-db.js +38 -2
  28. package/modules/government/catalog.js +1 -1
  29. package/modules/government/docket.js +20 -2
  30. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  31. package/modules/government/routes/government.js +43 -1
  32. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  33. package/modules/hall-ui/public/approval-queue.js +1 -0
  34. package/modules/hall-ui/public/brand-apply.js +3 -0
  35. package/modules/hall-ui/public/city-decide.js +642 -0
  36. package/modules/hall-ui/public/city-draw.js +14 -3
  37. package/modules/hall-ui/public/city-switchboard.js +141 -0
  38. package/modules/hall-ui/public/city.css +176 -10
  39. package/modules/hall-ui/public/city.html +8 -3
  40. package/modules/hall-ui/public/city.js +185 -38
  41. package/modules/hall-ui/public/sky-data.js +4 -1
  42. package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
  43. package/modules/hall-ui/public/tweak-editor.css +2 -0
  44. package/modules/hall-ui/public/tweak-editor.js +3 -1
  45. package/modules/lifecycle/db-claims.js +20 -5
  46. package/modules/lifecycle/rebase-gate.js +78 -0
  47. package/modules/lifecycle/routes/claims.js +10 -2
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/release-notes.json +28 -0
  51. package/scripts/gds/ask.js +68 -1
  52. package/scripts/gds/claim.js +6 -1
  53. package/scripts/gds/copy-apply.js +2 -0
  54. package/src/bongos/module-promotion.js +219 -0
  55. package/src/bongos/route-rank-check.js +7 -0
  56. package/src/bongos/routes/me.js +11 -0
  57. package/src/bongos/routes/modules.js +46 -0
  58. package/src/module-api.js +1 -1
  59. package/src/platform-server.js +5 -0
  60. package/tests/claim_gate_ci_unblock.mjs +4 -0
  61. package/tests/claim_gate_rebase.mjs +56 -3
  62. package/tests/copy_desk_page_editor.mjs +1 -1
  63. package/tests/copy_desk_page_submit.mjs +42 -0
  64. package/tests/copy_guarded_lines.mjs +54 -0
  65. package/tests/copy_stuck_page_tweaks.mjs +118 -0
  66. package/tests/government_ask_flags.mjs +80 -0
  67. package/tests/government_seed.mjs +1 -0
  68. package/tests/government_treasury.mjs +1 -0
  69. package/tests/governor_docket.mjs +1 -1
  70. package/tests/hall_approval_queue.mjs +15 -0
  71. package/tests/hall_city.mjs +5 -4
  72. package/tests/hall_city_living.mjs +118 -12
  73. package/tests/hall_city_rewording.mjs +51 -0
  74. package/tests/hall_city_switchboard.mjs +63 -0
  75. package/tests/hall_gov_real_site.mjs +2 -2
  76. package/tests/hall_sky.mjs +18 -0
  77. package/tests/hall_street_deck.mjs +6 -4
  78. package/tests/hall_street_depth.mjs +1 -1
  79. package/tests/hall_street_desk.mjs +5 -3
  80. package/tests/hall_tweak_editor.mjs +26 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/rebase_gate_set_aside.mjs +73 -0
  83. package/tests/reward_mode_surfacing.mjs +12 -0
@@ -242,7 +242,13 @@
242
242
  ring.appendChild(arc);
243
243
  head.appendChild(ring);
244
244
  }
245
- if (pin.state !== 'settled') {
245
+ // the switchboard's marker: a small toggle, on or off
246
+ if (pin.state === 'switch') {
247
+ el.className += pin.on ? ' city-pin--on' : ' city-pin--off';
248
+ var tg = span('city-pin__toggle');
249
+ tg.appendChild(span('city-pin__toggle-knob'));
250
+ head.appendChild(tg);
251
+ } else if (pin.state !== 'settled') {
246
252
  var svg = document.createElementNS(SVG_NS, 'svg');
247
253
  svg.setAttribute('width', '14');
248
254
  svg.setAttribute('height', '14');
@@ -433,7 +439,7 @@
433
439
  // route the deck takes (GovDeck.sendAnswer: the desk row's own request); then
434
440
  // two doors, "decide all N in a row" to the deck and "go inside" to the
435
441
  // building on the street. Without the deck's scripts it is the row and doors.
436
- var here = { done: {}, busy: false, said: '', room: null, roomAsked: false, cv: null };
442
+ var here = { done: {}, busy: false, said: '', room: null, roomAsked: false, cv: null, at: 0, n: 0 };
437
443
  function civicOf(key) { return model && model.civic ? model.civic.filter(function (c) { return c.key === key; })[0] || null : null; }
438
444
  // The building's button: the keyboard's way in, and where the card is placed beside.
439
445
  function hitFor(key) {
@@ -449,14 +455,16 @@
449
455
  function openCard(key, from) {
450
456
  var was = openKey;
451
457
  openKey = key;
452
- if (was !== key) { here.said = ''; here.cv = null; }
458
+ if (was !== key) { here.said = ''; here.cv = null; here.at = 0; }
453
459
  if (was && was !== key) showPlate(was, false);
454
460
  showPlate(key, true);
455
461
  var el = renderCard(key);
456
462
  if (!el) return;
457
463
  place(el, from || hitFor(key));
458
- var first = el.querySelector ? el.querySelector('.ask__opt, .city-card__go, .city-card__enter') : null;
459
- if (first && first.focus) first.focus();
464
+ // the first thing to act on takes focus, without scrolling the card: it always opens at its top
465
+ var first = el.querySelector ? el.querySelector('.ask__opt, .swb__toggle, .city-card__go, .city-card__arrow') : null;
466
+ if (first && first.focus) { try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); } }
467
+ el.scrollTop = 0;
460
468
  }
461
469
  function closeCard() {
462
470
  var el = $('cityCard');
@@ -473,9 +481,15 @@
473
481
  function firstAsk(key) {
474
482
  var K = window.GovDeck;
475
483
  if (!K || !K.drawAsk || !data.living || !data.docket) return null;
476
- var q = K.queueOf(data.living, { done: here.done, delegated: {}, skipped: [], extra: [] }, key);
484
+ // what is out for a rewrite leaves the queue until its new words land (city-decide.js)
485
+ var G = window.GovCityDecide;
486
+ var done = G && G.withoutReworded ? G.withoutReworded(here.done, data.docket) : here.done;
487
+ var q = K.queueOf(data.living, { done: done, delegated: {}, skipped: [], extra: [] }, key);
488
+ here.n = q.length;
489
+ here.yours = q.filter(function (x) { return x.needs_you; }).length;
477
490
  if (!q.length) return null;
478
- var cv = K.cardView(q[0], { docket: data.docket, room: here.room, constitution: data.raw.constitution, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), now: Date.now(), viewerId: data.docket.viewer ? String(data.docket.viewer.id) : null });
491
+ if (here.at >= q.length) here.at = 0;
492
+ var cv = K.cardView(q[here.at], { docket: data.docket, room: here.room, constitution: data.raw.constitution, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), now: Date.now(), viewerId: data.docket.viewer ? String(data.docket.viewer.id) : null });
479
493
  // a sitting's ballot is read once, when a vote's card first needs it
480
494
  if (cv && cv.card && cv.card.loading && !here.roomAsked) {
481
495
  here.roomAsked = true;
@@ -497,46 +511,157 @@
497
511
  el.className = 'city-card gov-glass';
498
512
  el.setAttribute('data-building', key);
499
513
  el.setAttribute('aria-label', card.name);
500
- var head = node('div', 'city-card__head');
514
+ // a building that waits on you wears the project's colour and glows it
515
+ if (card.on_you) el.className += ' city-card--you';
516
+ // the head: its name, and "Total N / Yours M" top right
501
517
  var top = node('div', 'city-card__top');
502
- top.appendChild(node('p', 'city-card__name', card.name));
503
- var close = node('button', 'city-card__close');
504
- close.setAttribute('type', 'button');
505
- close.setAttribute('aria-label', 'Close');
506
- close.setAttribute('aria-keyshortcuts', 'Escape');
507
- close.appendChild(node('kbd', null, 'esc'));
508
- close.addEventListener('click', closeCard);
509
- top.appendChild(close);
510
- head.appendChild(top);
511
- // who holds it, one fact, and what waits there (salmon only while it is yours)
518
+ var name = node('p', 'city-card__name', card.name);
519
+ // who holds it is a hover hint now, not a line of its own
512
520
  var hold = K && K.holdWords ? K.holdWords(data.living, key) : card.plate ? (card.lead ? 'Led by ' + card.plate + '.' : 'The owner holds it until a Lead is seated.') : '';
513
- var words = [hold, card.fact && card.fact.text ? sentence(card.fact.text) : ''].filter(Boolean).join(' ');
514
- var line = node('p', 'city-card__f', words);
515
- var waits = K && K.waitWords ? K.waitWords(card.on_you, card.on_others) : sentence(countText(card));
516
- line.appendChild(node('span', card.on_you ? 'city-card__count city-card__count--you' : 'city-card__count', (words ? ' ' : '') + waits));
517
- head.appendChild(line);
518
- el.appendChild(head);
519
- var cv = firstAsk(key);
521
+ if (hold) name.setAttribute('title', hold);
522
+ top.appendChild(name);
523
+ var total = card.open != null ? card.open : (card.on_you || 0) + (card.on_others || 0);
524
+ var mine = card.on_you || 0;
525
+ // what is still open after answers given here, as the decide view counts it
526
+ var cv0 = key !== 'automation' && window.GovDeck && data.living ? firstAsk(key) : null;
527
+ if (window.GovDeck && data.living && key !== 'automation') { total = here.n; mine = here.yours; }
528
+ var counts = node('span', 'city-card__counts', 'Total ' + total + ' / ');
529
+ counts.appendChild(node('b', mine ? 'city-card__count--you' : null, 'Yours ' + mine));
530
+ var RW = window.GovCityDecide;
531
+ var rw = RW && RW.rewordingCount ? RW.rewordingCount(data.docket, key) : 0;
532
+ if (rw) counts.appendChild(node('span', 'city-card__rewording', 'Rewording ' + rw));
533
+ // a switchboard has no queue to count
534
+ if (key !== 'automation' || !window.GovCitySwitchboard) top.appendChild(counts);
535
+ el.appendChild(top);
536
+ // Automation is a switchboard, not a queue (city-switchboard.js)
537
+ var SW = key === 'automation' ? window.GovCitySwitchboard : null;
538
+ var cv = SW ? null : cv0;
520
539
  here.cv = cv;
521
- if (cv) el.appendChild(K.drawAsk(document, cv, { pick: function (op) { answer(key, cv, op); } }, { inner: true, busy: here.busy, cls: 'city-ask' }));
540
+ if (SW) {
541
+ el.className += ' city-card--board';
542
+ SW.draw(document, el, data.docket, { now: Date.now(), viewerId: data.docket && data.docket.viewer ? String(data.docket.viewer.id) : null, goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }) }, {
543
+ busy: here.busy,
544
+ send: function (req) { here.busy = true; return send(req); },
545
+ done: function (toast, ok) {
546
+ here.busy = false;
547
+ here.said = toast;
548
+ var flashIt = function () { var f = $('cityCard'); if (ok && window.GovCityDecide && window.GovCityDecide.flash && f) window.GovCityDecide.flash(f, 'Switched'); };
549
+ if (ok) load().then(flashIt); else renderCard(key);
550
+ },
551
+ });
552
+ }
553
+ // the glow says it is yours: the 'only you can answer' line is left off
554
+ if (cv) {
555
+ // answerable in place: the desk row's acts, chips and typed fields are the card's own (city-decide.js)
556
+ var G = window.GovCityDecide;
557
+ var drawn = G && G.drawCard ? G.drawCard(document, K, cv, { pick: function (op) { answer(key, cv, op); }, said: function (t) { here.said = t; renderCard(key); }, reworded: function (t) { here.said = t; if (window.GovFeeds && window.GovFeeds.changed) window.GovFeeds.changed(); draw(painted || effectiveTheme()); }, send: send, docket: function () { return data.docket; } }, { inner: true, busy: here.busy, cls: 'city-ask' }) : { node: K.drawAsk(document, quiet(cv), { pick: function (op) { answer(key, cv, op); } }, { inner: true, busy: here.busy, cls: 'city-ask' }), cv: cv };
558
+ here.cv = drawn.cv;
559
+ el.appendChild(drawn.node);
560
+ // which of this building's items this is; Tab and the up / down arrows step through them
561
+ if (here.n > 1) {
562
+ var step = node('div', 'city-card__step');
563
+ var up = node('button', 'city-card__stepbtn', '↑');
564
+ up.setAttribute('type', 'button');
565
+ up.setAttribute('aria-label', 'Previous item');
566
+ up.setAttribute('aria-keyshortcuts', 'Shift+Tab ArrowUp');
567
+ up.addEventListener('click', function () { stepItem(-1); });
568
+ var down = node('button', 'city-card__stepbtn', '↓');
569
+ down.setAttribute('type', 'button');
570
+ down.setAttribute('aria-label', 'Next item');
571
+ down.setAttribute('aria-keyshortcuts', 'Tab ArrowDown');
572
+ down.addEventListener('click', function () { stepItem(1); });
573
+ step.appendChild(up);
574
+ step.appendChild(node('span', 'city-card__stepn', (here.at + 1) + ' of ' + here.n));
575
+ step.appendChild(down);
576
+ step.appendChild(node('span', 'city-card__hint', 'Tab · ↑ ↓ items ← → buildings'));
577
+ el.appendChild(step);
578
+ }
579
+ }
580
+ else if (!SW) el.appendChild(node('p', 'city-card__f', 'Nothing waits here.'));
522
581
  if (here.said) {
523
582
  var said = node('p', 'city-card__said', here.said);
524
583
  said.setAttribute('role', 'status');
525
584
  el.appendChild(said);
526
585
  }
586
+ // the foot: flip between buildings, decide them all, and a small esc
527
587
  var acts = node('div', 'city-card__acts');
528
- var n = card.on_you || card.on_others;
588
+ var prev = node('button', 'city-card__arrow', '‹');
589
+ prev.setAttribute('type', 'button');
590
+ prev.setAttribute('aria-label', 'Previous building');
591
+ prev.setAttribute('aria-keyshortcuts', 'ArrowLeft');
592
+ prev.addEventListener('click', function () { flipCard(-1); });
593
+ var next = node('button', 'city-card__arrow', '›');
594
+ next.setAttribute('type', 'button');
595
+ next.setAttribute('aria-label', 'Next building');
596
+ next.setAttribute('aria-keyshortcuts', 'ArrowRight');
597
+ next.addEventListener('click', function () { flipCard(1); });
598
+ acts.appendChild(prev);
599
+ acts.appendChild(next);
600
+ // what is still open here, after answers and rewrites (firstAsk counted it)
601
+ var n = cv ? here.n : (card.on_you || card.on_others);
529
602
  if (n) {
530
- var go = node('a', 'city-card__go', n === 1 ? 'decide it on the deck' : 'decide all ' + n + ' in a row');
531
- go.setAttribute('href', card.deck_href);
603
+ var go = node('button', 'city-card__go', n === 1 ? 'decide it' : 'decide all ' + n);
604
+ go.setAttribute('type', 'button');
605
+ go.addEventListener('click', function () { openDecide(key); });
532
606
  acts.appendChild(go);
533
607
  }
534
- var enter = node('a', 'city-card__enter', 'go inside');
535
- enter.setAttribute('href', card.enter_href);
536
- acts.appendChild(enter);
608
+ acts.appendChild(node('span', 'city-card__gap'));
609
+ var close = node('button', 'city-card__close');
610
+ close.setAttribute('type', 'button');
611
+ close.setAttribute('aria-label', 'Close');
612
+ close.setAttribute('aria-keyshortcuts', 'Escape');
613
+ close.appendChild(node('kbd', null, 'esc'));
614
+ close.addEventListener('click', closeCard);
615
+ acts.appendChild(close);
537
616
  el.appendChild(acts);
617
+ // a pick waiting on its confirm survives a redraw of the same card, never a move to another
618
+ var G2 = window.GovCityDecide;
619
+ if (el.__armed && (!here.cv || here.cv.id !== el.__armed.id)) el.__armed = null;
620
+ else if (G2 && G2.paintArmed) G2.paintArmed(el);
538
621
  return el;
539
622
  }
623
+ function quiet(cv) { var f = cv.foot || {}; var c = {}; for (var k in cv) c[k] = cv[k]; c.foot = { you: '', rest: f.rest || '' }; return c; }
624
+ // Tab / ↑ ↓ step through this building's items (wrapping)
625
+ function stepItem(d) {
626
+ if (!openKey || here.n < 2) return;
627
+ here.at = (here.at + d + here.n) % here.n;
628
+ here.said = '';
629
+ var el = renderCard(openKey);
630
+ var first = el && el.querySelector ? el.querySelector('.ask__opt, .ask-field__input') : null;
631
+ if (first && first.focus) first.focus();
632
+ }
633
+ // ← / → walk the buildings that have something open, else all of them
634
+ function flipCard(d) {
635
+ if (!model || !model.civic || !openKey) return;
636
+ var keys = model.civic.filter(function (c) { return c.key === 'automation' || (c.card && (c.card.open || c.card.on_you || c.card.on_others)); }).map(function (c) { return c.key; });
637
+ if (keys.indexOf(openKey) < 0) keys = model.civic.map(function (c) { return c.key; });
638
+ var i = keys.indexOf(openKey);
639
+ var to = keys[(i + d + keys.length) % keys.length];
640
+ if (to && to !== openKey) openCard(to);
641
+ }
642
+ // the decide view (city-decide.js): one by one or a table, over the city
643
+ var decide = null;
644
+ // how: 'one' (default) or 'table'
645
+ function openDecide(scope, how) {
646
+ if (!window.GovCityDecide || !window.GovDeck) { window.location.href = City.STREET_HREF + '#deck' + (scope ? '=' + scope : ''); return; }
647
+ if (!decide) decide = window.GovCityDecide.make({
648
+ data: function () { return data; },
649
+ done: here.done,
650
+ send: send,
651
+ changed: function () { if (window.GovFeeds && window.GovFeeds.changed) window.GovFeeds.changed(); draw(painted || effectiveTheme()); },
652
+ root: function () { return $('hall-main'); },
653
+ room: function () { return here.room; },
654
+ });
655
+ if (openKey) closeCard();
656
+ if (!here.roomAsked) {
657
+ here.roomAsked = true;
658
+ getJson('/api/bongos/government/board/items').then(function (r) {
659
+ here.room = r && (Array.isArray(r.open) || Array.isArray(r.closed)) ? r : { open: [], closed: [] };
660
+ if (decide && decide.isOpen()) decide.render();
661
+ });
662
+ }
663
+ decide.open(scope || null, how);
664
+ }
540
665
  // One answer, through the deck's own sender: the same request its desk row sends.
541
666
  function send(req) {
542
667
  return fetch(req.url, { method: req.method, credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(req.body || {}) })
@@ -560,8 +685,12 @@
560
685
  // every count on the page leaves out what was answered here (gov-feeds.js)
561
686
  if (window.GovFeeds && window.GovFeeds.changed) window.GovFeeds.changed();
562
687
  }
563
- if (res.ok && res.stored) return load().then(function () { return true; });
688
+ // a decision lands with a beat you can see: the card pulses and says so (city-decide.js flash)
689
+ var G = window.GovCityDecide;
690
+ var cheer = function () { var f = $('cityCard'); if (res.ok && G && G.flash && f) G.flash(f, 'Decided'); };
691
+ if (res.ok && res.stored) return load().then(function () { cheer(); return true; });
564
692
  if (openKey) { draw(painted || effectiveTheme()); var f = $('cityCard'); var first = f && f.querySelector ? f.querySelector('.ask__opt, .city-card__go') : null; if (first && first.focus) first.focus(); }
693
+ cheer();
565
694
  return res.ok;
566
695
  });
567
696
  }
@@ -569,10 +698,19 @@
569
698
  function cardKey(e) {
570
699
  if (!e) return;
571
700
  if (e.key === 'Escape') { closeCard(); return; }
572
- if (!here.cv || !openKey || e.ctrlKey || e.metaKey || e.altKey || !/^[1-3]$/.test(String(e.key))) return;
701
+ if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && !e.metaKey && !e.altKey && !(e.target && /^(INPUT|TEXTAREA)$/.test(String(e.target.tagName)))) { if (e.preventDefault) e.preventDefault(); flipCard(e.key === 'ArrowLeft' ? -1 : 1); return; }
702
+ var inText = e.target && /^(INPUT|TEXTAREA)$/.test(String(e.target.tagName));
703
+ // Tab is the next thing that needs you in this building, never the next button
704
+ if (e.key === 'Tab' && openKey && !inText) { if (e.preventDefault) e.preventDefault(); stepItem(e.shiftKey ? -1 : 1); return; }
705
+ if (inText) return;
706
+ if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && openKey) { if (e.preventDefault) e.preventDefault(); stepItem(e.key === 'ArrowUp' ? -1 : 1); return; }
707
+ if (!here.cv || !openKey || e.ctrlKey || e.metaKey || e.altKey || !/^[1-9]$/.test(String(e.key))) return;
573
708
  var op = here.cv.options[Number(e.key) - 1];
574
709
  if (!op) return;
575
710
  if (e.preventDefault) e.preventDefault();
711
+ // the first press picks that answer, the same number again confirms it
712
+ var G = window.GovCityDecide;
713
+ if (G && G.armKey && !G.armKey($('cityCard'), here.cv, Number(e.key))) return;
576
714
  answer(openKey, here.cv, op);
577
715
  }
578
716
  // Beside the building on a wide stage, over a light hush with the building
@@ -603,15 +741,24 @@
603
741
 
604
742
  function boot() {
605
743
  var start = $('cityStart');
606
- if (start) start.setAttribute('href', City.STREET_HREF);
744
+ // the big button opens the decide view in place; the street is archived
745
+ if (start) start.setAttribute('href', '#decide');
746
+ if (start) start.addEventListener('click', function (e) { if (window.GovCityDecide && window.GovDeck) { if (e.preventDefault) e.preventDefault(); openDecide(null); } });
747
+ window.addEventListener('hashchange', function () { var h = window.location && window.location.hash; if (/^#decide/.test(h || '')) openDecide(h.split('=')[1] || null); });
607
748
  var btn = $('cityMode');
608
749
  if (btn) btn.addEventListener('click', toggleTheme);
609
750
  if (media && media.addEventListener) media.addEventListener('change', function () { if (!storedTheme()) draw(applyTheme()); });
610
751
  window.addEventListener('resize', fit);
611
752
  var card = $('cityCard');
753
+ // the card's keys work wherever focus is while it is open (Tab never walks its buttons)
612
754
  if (card) card.addEventListener('keydown', cardKey);
755
+ if (document.addEventListener) document.addEventListener('keydown', function (e) {
756
+ var t = e && e.target;
757
+ if (!openKey || e.defaultPrevented || (decide && decide.isOpen()) || (card && t && card.contains && card.contains(t))) return;
758
+ cardKey(e);
759
+ });
613
760
  // the page's counts leave out what the card answered (gov-feeds.js)
614
- if (window.GovFeeds && typeof window.GovFeeds.useSession === 'function') window.GovFeeds.useSession(function () { return { answered: Object.keys(here.done), extra: [] }; });
761
+ if (window.GovFeeds && typeof window.GovFeeds.useSession === 'function') window.GovFeeds.useSession(function () { var G = window.GovCityDecide; return { answered: Object.keys(G && G.withoutReworded ? G.withoutReworded(here.done, data.docket) : here.done), extra: [] }; });
615
762
  var frame = $('cityFrame');
616
763
  // a click anywhere on the city but a building (its faces, its pin, its plate) closes the card
617
764
  if (frame) frame.addEventListener('click', function (e) {
@@ -623,9 +770,9 @@
623
770
  painted = applyTheme();
624
771
  draw(painted);
625
772
  watchShellTheme();
626
- return load();
773
+ return load().then(function (r) { var h = window.location && window.location.hash; if (/^#decide/.test(h || '')) openDecide(h.split('=')[1] || null); return r; });
627
774
  }
628
775
 
629
- window.GovCityPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, FEEDS: FEEDS, LIVING_FEEDS: LIVING_FEEDS, THEME_KEY: THEME_KEY };
776
+ window.GovCityPage = { boot: boot, draw: draw, load: load, fit: fit, openCard: openCard, closeCard: closeCard, openDecide: function (scope, how) { openDecide(scope, how); return decide; }, FEEDS: FEEDS, LIVING_FEEDS: LIVING_FEEDS, THEME_KEY: THEME_KEY };
630
777
  window.GovCityPage.ready = boot();
631
778
  }());
@@ -147,7 +147,10 @@
147
147
  id: String(g.goal_id), title: g.title || 'A private goal', subtitle: g.subtitle || null, version: g.version_id, status: g.status,
148
148
  taskTotal: Number(g.task_total) || 0, taskDone: Number(g.task_done) || 0, createdAt: g.created_at || null,
149
149
  crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true, uat: c.uat_state || null, uatLabel: uatLabel(c.uat_state), backendOnly: c.backend_only === true })),
150
- tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
150
+ // task 1004782 — abandoned (or merged-away) work carries no credits: it is listed, at zero, and never adds to a
151
+ // well's mass or a ring. A merged-away task is always abandoned (tasks.merged_into_task_id is CHECK-constrained to
152
+ // abandoned rows), so the status test covers both; /sky does not send the pointer.
153
+ tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.status === 'abandoned' || t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
151
154
  };
152
155
  }
153
156
  const taskRow = (t) => ({ id: String(t.task_id ?? t.id), title: t.title || 'a task', status: t.status, goalId: t.goal_id != null ? String(t.goal_id) : null, origin: t.origin || null });
@@ -25,7 +25,7 @@
25
25
  'use strict';
26
26
 
27
27
  // The ADR's words (D4), and the read's own `notes` wins when it carries them.
28
- const NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer' });
28
+ const NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
29
29
 
30
30
  function sectionsOf(lines) {
31
31
  const out = [];
@@ -43,12 +43,31 @@
43
43
  return typeof value === 'string' && value !== line.text;
44
44
  }
45
45
 
46
+ // task 1004776: the phrases a guarded line's new text no longer contains (the
47
+ // server's guarded-lines.js rule, case-insensitive), so the warning shows while
48
+ // the artist types, not only at submit.
49
+ function droppedPhrases(line, value) {
50
+ const t = String(value == null ? '' : value).toLowerCase();
51
+ return (line.must_contain || []).filter((p) => !t.includes(String(p).toLowerCase()));
52
+ }
53
+
54
+ // The submit's warning (the server's `warnings`), as plain sentences; the
55
+ // editor escapes them. Empty when nothing was warned.
56
+ function submitWarningHtml(warnings) {
57
+ return (warnings || []).map((w) => `A line you changed no longer says what it must: ${w.must_still_say} It was sent anyway; the person approving it will see this.`);
58
+ }
59
+
46
60
  // The mock's rule: a shared or engineer line always shows its own note, and
47
61
  // any other changed line shows the words it replaced. An emptied line says it
48
62
  // will be removed first, since that is what sending it now does.
49
63
  function noteFor(line, value, notes) {
50
64
  const n = notes || NOTES;
51
65
  if (isRemoved(line, value)) return { kind: 'removed', text: `to be removed · goes to an engineer · was: ${line.text}` };
66
+ // task 1004776: a line that must keep its meaning says what it must still say.
67
+ if (line.note === 'guarded' && line.must_still_say) {
68
+ const drops = isChanged(line, value) && droppedPhrases(line, value).length;
69
+ return { kind: 'guarded', text: `${drops ? 'this no longer says what it must · ' : ''}${n.guarded || 'must still say'}: ${line.must_still_say}` };
70
+ }
52
71
  if (line.note === 'shared') return { kind: 'shared', text: n.shared || NOTES.shared };
53
72
  if (line.note === 'engineer') return { kind: 'engineer', text: n.engineer || NOTES.engineer };
54
73
  if (isChanged(line, value)) return { kind: 'was', text: `was: ${line.text}` };
@@ -260,7 +279,7 @@
260
279
  return null;
261
280
  }
262
281
 
263
- const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, isRemoved, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
282
+ const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, isRemoved, droppedPhrases, noteFor, draftBody, submitWarningHtml, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
264
283
  if (typeof window !== 'undefined') window.OTBTweakEditor = api;
265
284
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
266
285
  })();
@@ -412,6 +412,8 @@ html[data-fullscreen="on"] .tw-frame { left: 0; }
412
412
  .tw-note { padding-top: 9px; font-size: 12px; line-height: 1.4; color: var(--tw-paper-faint); overflow-wrap: anywhere; }
413
413
  .tw-note--shared { color: var(--tw-shared); }
414
414
  .tw-note--engineer { color: var(--tw-engineer); }
415
+ /* task 1004776: a line that must keep its meaning. */
416
+ .tw-note--guarded { color: var(--tw-engineer); font-weight: 600; }
415
417
  .tw-sr {
416
418
  position: absolute;
417
419
  width: 1px;
@@ -525,7 +525,9 @@
525
525
  }
526
526
  st.submitted = true;
527
527
  st.writable = false;
528
- notice('');
528
+ // task 1004776: a guarded line that dropped its promise is warned, never refused.
529
+ const warned = lib.submitWarningHtml(r.data && r.data.warnings);
530
+ notice(warned.length ? warned.map((s) => `<p>${escapeHtml(s)}</p>`).join('') : '');
529
531
  document.querySelectorAll('textarea.tw-line').forEach((ta) => { ta.readOnly = true; });
530
532
  document.querySelectorAll('.tw-line--holes').forEach((box) => { box.setAttribute('contenteditable', 'false'); });
531
533
  document.querySelectorAll('.tw-ctl').forEach((c) => { c.hidden = true; });
@@ -36,6 +36,7 @@ const { siblingOverlaps, RESOLVED_STATUSES } = require('./goal-conflicts.js');
36
36
  const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
37
37
  const { sealSessionLog } = require('./db-shared.js');
38
38
  const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
39
+ const { splitRebaseGate, rebaseRefusalTasks } = require('./rebase-gate.js');
39
40
  const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
40
41
  const { autoPickupRefusal } = require('./autopickup.js');
41
42
 
@@ -135,13 +136,16 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
135
136
  // ship that clears the last flag.
136
137
  // The GATING subset, not the surfacing one: a strand the builder cannot clear
137
138
  // stops blocking after a grace, while still being reported (task 1003294).
138
- const owedRebases = await gatingRebaseTasksForBuilder(builderId, client);
139
- if (owedRebases.length > 0 && !isRebaseGateExempt(
139
+ // task 1004788: split by the shared predicate (rebase-gate.js) — a strand that
140
+ // waits on something outside the builder never gates, and fixable ones gate
141
+ // only once REBASE_GATE_LIMIT of them pile up.
142
+ const rebaseSplit = splitRebaseGate(await gatingRebaseTasksForBuilder(builderId, client));
143
+ if (rebaseSplit.gates && !isRebaseGateExempt(
140
144
  (await client.query(`SELECT kind, priority FROM tasks WHERE id = $1`, [taskId])).rows[0],
141
145
  )) {
142
146
  throw {
143
147
  code: 'REBASE_REQUIRED',
144
- rebaseTasks: owedRebases.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
148
+ rebaseTasks: rebaseRefusalTasks(rebaseSplit),
145
149
  };
146
150
  }
147
151
 
@@ -539,11 +543,19 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
539
543
 
540
544
  // Lock every requested task row up front (sorted to avoid deadlocks).
541
545
  const { rows: taskRows } = await client.query(
542
- `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, goal_id FROM tasks WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE`,
546
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, goal_id, kind, priority FROM tasks WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE`,
543
547
  [uniqIds]
544
548
  );
545
549
 
546
550
  const failures = [];
551
+ // task 1004788: the REBASE_REQUIRED gate, by the SAME predicate as claimTask
552
+ // (rebase-gate.js). Before this the batch path skipped the gate entirely.
553
+ const batchRebaseSplit = splitRebaseGate(await gatingRebaseTasksForBuilder(builderId, client));
554
+ if (batchRebaseSplit.gates) {
555
+ for (const t of taskRows) {
556
+ if (!isRebaseGateExempt(t)) failures.push({ task_id: t.id, reason: 'REBASE_REQUIRED', rebaseTasks: rebaseRefusalTasks(batchRebaseSplit) });
557
+ }
558
+ }
547
559
  // node-pg returns bigint/bigserial columns (tasks.id) as JS STRINGS, not
548
560
  // numbers (learning #94) — Number() them before comparing against uniqIds
549
561
  // (which are already numbers). Without this, foundIds.has(requestedId)
@@ -939,10 +951,12 @@ async function validateClaimBatch({ taskIds, builderId }) {
939
951
  const builderRank = builderRows[0] ? builderRows[0].rank : null;
940
952
 
941
953
  const { rows: taskRows } = await pool.query(
942
- `SELECT id, status, touches, newcomer_friendly, requires_rank, module_key, goal_id
954
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, module_key, goal_id, kind, priority
943
955
  FROM tasks WHERE id = ANY($1::bigint[])`,
944
956
  [uniqIds]
945
957
  );
958
+ // task 1004788: the dry run asks the same rebase-gate question as the real paths.
959
+ const dryRebaseSplit = splitRebaseGate(await gatingRebaseTasksForBuilder(builderId, pool));
946
960
  // node-pg returns tasks.id (bigserial) as a STRING (learning #94) — Number()
947
961
  // it so lookups below (byId.get(id), id from uniqIds) actually hit.
948
962
  const byId = new Map(taskRows.map((r) => [Number(r.id), r]));
@@ -968,6 +982,7 @@ async function validateClaimBatch({ taskIds, builderId }) {
968
982
  if (builderInactive) push(id, { reason: 'BUILDER_INACTIVE' });
969
983
  const t = byId.get(id);
970
984
  if (!t) { push(id, { reason: 'TASK_NOT_FOUND' }); continue; }
985
+ if (dryRebaseSplit.gates && !isRebaseGateExempt(t)) push(id, { reason: 'REBASE_REQUIRED', rebaseTasks: rebaseRefusalTasks(dryRebaseSplit) });
971
986
  if (t.status !== 'ready') push(id, { reason: 'TASK_NOT_READY', currentStatus: t.status });
972
987
  // task 1003196: same composed decision as claimTask and claimTasksBatch, and
973
988
  // the same deliberate xenos-only queue short-circuit the batch path carries.
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // rebase-gate.js — the ONE predicate behind the REBASE_REQUIRED claim gate, shared
4
+ // by all three claim surfaces (claimTask, claimTasksBatch, validateClaimBatch; task
5
+ // 1004788, ADR 0120 part 3).
6
+ //
7
+ // WHY. The gate refused EVERY new claim while the builder had ANY confirmed task
8
+ // flagged needs_rebase. On 2026-10-23 one task parked on a server-side guard
9
+ // (strand:branch_modifies_executed_code, which no action of its builder clears)
10
+ // plus two peer-session strands froze a six-task build chain for over an hour.
11
+ // Owner: "if i send something for lars to do it shouldnt shut down all other
12
+ // merges and ships" — and, refining it, such a task is SET ASIDE: out of the claim
13
+ // gate, marked as waiting with what it waits on, and still swept, so it re-enters
14
+ // the land on its own.
15
+ //
16
+ // THE RULE. Each owed rebase (gatingRebaseTasksForBuilder's rows) is split:
17
+ // • WAITING — stuck on something outside the builder. Its reason names a
18
+ // server-side guard (branch_modifies_executed_code: the conflict resolver will
19
+ // not run code the branch changes; a person or a moved main lands it) or an
20
+ // explicit hold (a `waiting:<what>` reason). It never gates. It stays flagged,
21
+ // reported, and swept by the publish-reconciler every pass, so the moment the
22
+ // thing it waits on clears (main moves so it merges clean, the branch is
23
+ // brought up to date, a person lands it) the ordinary sweep lands it and ship
24
+ // clears the flag. Set aside, never dropped.
25
+ // • FIXABLE — everything else (an unresolvable hand-written conflict, a red
26
+ // check, an unpushed branch). These are the builder's to fix, and the gate
27
+ // keeps its purpose for them — but only once they pile up: it refuses at
28
+ // REBASE_GATE_LIMIT fixable strands, not on the first one.
29
+ //
30
+ // Pure: no DB. The rows come from db-ship.gatingRebaseTasksForBuilder, which keeps
31
+ // its own unprovable-strand grace (task 1003294).
32
+
33
+ // Fixable strands at which new claims are refused. Read per call so an operator
34
+ // (or a test) can move it with REBASE_GATE_LIMIT without a restart.
35
+ const REBASE_GATE_LIMIT = 3;
36
+ function gateLimit() { return Math.max(1, Number(process.env.REBASE_GATE_LIMIT) || REBASE_GATE_LIMIT); }
37
+
38
+ // Reasons (as stored: `strand:<reason>[:files]`) that wait on something outside
39
+ // the builder, with the words the refusal and the CLI say for each.
40
+ const WAITING_REASONS = Object.freeze({
41
+ branch_modifies_executed_code: 'a server-side guard: the merge resolver will not run code this branch changes, so it lands once main moves or a person lands it',
42
+ });
43
+
44
+ // waitsOn(reason) -> null (the builder's to fix) | string (what it waits on).
45
+ function waitsOn(reason) {
46
+ const r = String(reason == null ? '' : reason);
47
+ const m = /^waiting:(.+)$/.exec(r);
48
+ if (m) return m[1].trim() || 'something outside the builder';
49
+ const key = r.replace(/^strand:/, '').split(':')[0];
50
+ return Object.prototype.hasOwnProperty.call(WAITING_REASONS, key) ? WAITING_REASONS[key] : null;
51
+ }
52
+
53
+ // splitRebaseGate(rows, { limit }) -> { gates, gating, waiting, fixable }
54
+ // gating — the rows that refuse (all fixable rows once there are `limit` of them; else [])
55
+ // waiting — set-aside rows, each with waits_on
56
+ // fixable — every fixable row, gating or not
57
+ function splitRebaseGate(rows, { limit = gateLimit() } = {}) {
58
+ const waiting = [];
59
+ const fixable = [];
60
+ for (const t of rows || []) {
61
+ const w = waitsOn(t && t.needs_rebase_reason);
62
+ if (w) waiting.push({ ...t, waits_on: w });
63
+ else fixable.push(t);
64
+ }
65
+ const gates = fixable.length >= limit;
66
+ return { gates, gating: gates ? fixable : [], waiting, fixable };
67
+ }
68
+
69
+ // The refusal's task list: gating tasks as before, plus the waiting ones marked so
70
+ // the hint and the CLI can say "waiting, not on you".
71
+ function rebaseRefusalTasks(split) {
72
+ return [
73
+ ...split.gating.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
74
+ ...split.waiting.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason, waiting: true, waits_on: t.waits_on })),
75
+ ];
76
+ }
77
+
78
+ module.exports = { REBASE_GATE_LIMIT, WAITING_REASONS, waitsOn, splitRebaseGate, rebaseRefusalTasks };
@@ -97,9 +97,17 @@ function taskNotReadyHint(taskId, currentStatus) {
97
97
  // up-to-date branch and stays a fast-forward, which is why the recovery text in
98
98
  // ship-land.js already names merge and explicitly says "Do NOT reset or
99
99
  // re-rebase". This is the same advice, given BEFORE the deadlock instead of after.
100
+ // task 1004788: only the FIXABLE tasks gate (rebase-gate.js); a task set aside
101
+ // because it waits on something outside the builder is listed after them as
102
+ // "waiting, not on you" so nobody chases it.
100
103
  function rebaseRequiredHint(rebaseTasks) {
101
- const ids = (rebaseTasks || []).map((t) => `task ${t.id}`);
102
- return `Your queue is gated: ${ids.length} confirmed task(s) are waiting to land (${ids.join(', ') || 'unknown'}). Clear it with /strand-fix N (works on any machine), or bring the branch up to date with \`git merge origin/main\` and re-ship — MERGE, never rebase: server-mediated publish is append-only, and rewriting a published branch makes it un-pushable (task 1002788). /merge-mode only if this machine has a GitHub push credential. To give up on one instead, /builder-release it. If CI is simply backed up, waiting is correct: a task already landed clears the gate on its own. If main's CI is red for a reason outside your branch, the fix is still claimable: a P1 kind=infra task passes this gate (task 1004370).`;
104
+ const all = rebaseTasks || [];
105
+ const ids = all.filter((t) => !t.waiting).map((t) => `task ${t.id}`);
106
+ const waiting = all.filter((t) => t.waiting);
107
+ const aside = waiting.length
108
+ ? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) => `task ${t.id} (${t.waits_on || 'outside you'})`).join('; ')}.`
109
+ : '';
110
+ return `Your queue is gated: ${ids.length} confirmed task(s) of yours are waiting to land and need you (${ids.join(', ') || 'unknown'}).${aside} Clear it with /strand-fix N (works on any machine), or bring the branch up to date with \`git merge origin/main\` and re-ship — MERGE, never rebase: server-mediated publish is append-only, and rewriting a published branch makes it un-pushable (task 1002788). /merge-mode only if this machine has a GitHub push credential. To give up on one instead, /builder-release it. If CI is simply backed up, waiting is correct: a task already landed clears the gate on its own. If main's CI is red for a reason outside your branch, the fix is still claimable: a P1 kind=infra task passes this gate (task 1004370).`;
103
111
  }
104
112
 
105
113
  // task 1642 (+ the worktree-keying fix) — the pure one-active-claim decision.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.166",
3
+ "version": "1.21.168",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.166",
9
+ "version": "1.21.168",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.166",
3
+ "version": "1.21.168",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",