@bongos/core 1.21.167 → 1.21.169

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 +157 -72
  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 +308 -5
  8. package/docs/api-reference.md +7 -3
  9. package/docs/architecture.md +2 -0
  10. package/docs/copy-inventory.md +21 -20
  11. package/docs/copy-registry.json +34 -25
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/modules-contract.md +2 -1
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +430 -430
  16. package/modules/autonomy/governor-docket.js +10 -5
  17. package/modules/builder-settings/builder-needs.js +29 -3
  18. package/modules/copy-desk/guarded-lines.js +89 -0
  19. package/modules/copy-desk/guarded-lines.json +14 -0
  20. package/modules/copy-desk/module.json +2 -2
  21. package/modules/copy-desk/page-status.js +77 -7
  22. package/modules/copy-desk/pages.js +4 -0
  23. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  24. package/modules/copy-desk/routes/copy-desk.js +8 -0
  25. package/modules/copy-desk/stuck-tweaks.js +79 -0
  26. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  27. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  28. package/modules/government/asks-db.js +38 -2
  29. package/modules/government/catalog.js +1 -1
  30. package/modules/government/docket.js +20 -2
  31. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  32. package/modules/government/routes/government.js +43 -1
  33. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  34. package/modules/hall-ui/public/approval-queue.js +1 -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/tweak-editor-lib.js +21 -2
  42. package/modules/hall-ui/public/tweak-editor.css +2 -0
  43. package/modules/hall-ui/public/tweak-editor.js +3 -1
  44. package/modules/lifecycle/db-claims.js +20 -5
  45. package/modules/lifecycle/rebase-gate.js +78 -0
  46. package/modules/lifecycle/routes/claims.js +10 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +28 -0
  50. package/scripts/gds/ask.js +68 -1
  51. package/scripts/gds/claim.js +6 -1
  52. package/scripts/gds/copy-apply.js +2 -0
  53. package/src/bongos/module-promotion.js +219 -0
  54. package/src/bongos/module-store.js +47 -9
  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 +72 -10
  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_street_deck.mjs +6 -4
  77. package/tests/hall_street_depth.mjs +1 -1
  78. package/tests/hall_street_desk.mjs +5 -3
  79. package/tests/hall_tweak_editor.mjs +26 -0
  80. package/tests/module_channel_install.mjs +112 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/module_upgrade_updates.mjs +2 -2
  83. package/tests/rebase_gate_set_aside.mjs +73 -0
@@ -181,7 +181,7 @@ test('salmon only for what waits on the viewer: on the floor whose seat it waits
181
181
  // a stranger: nothing waits on them, so nothing is salmon and the door is plain
182
182
  const s = city({ me: STRANGER });
183
183
  assert.equal(allPolys(s).filter((x) => /city-win--you/.test(x.p.cls || '')).length, 0);
184
- assert.ok(s.pins.every((p) => p.state === 'moving' || p.state === 'settled'), 'no salmon pin');
184
+ assert.ok(s.pins.every((p) => p.state === 'moving' || p.state === 'settled' || p.state === 'switch'), 'no salmon pin (the switchboard marker is ink)');
185
185
  assert.deepEqual(s.cta, City.ctaFor(0));
186
186
  assert.equal(s.cta.label, 'start governing');
187
187
  });
@@ -249,7 +249,7 @@ test('quiet buildings sit darker; busy ones keep the prototype\'s recipe exactly
249
249
  if (b.quiet) assert.notDeepEqual(recipe, ref.map((p) => p.f), `${s[0]} is quiet: its faces are mixed toward the void`);
250
250
  else assert.deepEqual(recipe, ref.map((p) => p.f), `${s[0]} is busy: the recipe is untouched`);
251
251
  }
252
- assert.deepEqual(m.civic.filter((c) => c.quiet).map((c) => c.key).sort(), ['people', 'rules'], 'no agent and no ask: Rules and People');
252
+ assert.deepEqual(m.civic.filter((c) => c.quiet).map((c) => c.key).sort(), ['automation', 'people', 'rules'], 'no agent and no ask: Automation (its switch is a standing state, not an ask), Rules and People');
253
253
  assert.equal(City.mixToward('#808080', '#000000', 0.5), '#404040');
254
254
  assert.equal(City.mixToward('rgba(255,255,255,0.4)', '#000000', 0.5), 'rgba(255,255,255,0.2)', 'a line thins out');
255
255
  });
@@ -323,7 +323,7 @@ test('the card: who holds it, one fact, its count, two doors (its first ask is t
323
323
  assert.equal(p.enter_href, '/builders/street#building=planning');
324
324
  for (const c of m.civic) assert.deepEqual(Object.keys(c.card).sort(), ['agents', 'deck_href', 'enter_href', 'fact', 'key', 'lead', 'name', 'on_others', 'on_you', 'open', 'plate'], c.key + ': every answer is the deck\'s');
325
325
  assert.equal(civicOf(m, 'charter').card.plate, 'owner', 'an unseated building is the owner\'s');
326
- assert.equal(civicOf(m, 'automation').card.fact.text, 'unattended off', 'the real fence');
326
+ assert.equal(civicOf(m, 'automation').card.fact.text, 'AI building: off', 'the real fence');
327
327
  assert.equal(civicOf(m, 'release').card.fact.text, '2 agents working');
328
328
  });
329
329
 
@@ -341,7 +341,7 @@ test('the door is "decide N in a row" to the deck; the line counts you, others a
341
341
  const m = city();
342
342
  const c = m.counts;
343
343
  assert.ok(c.on_you > 0 && c.on_others > 0);
344
- assert.deepEqual([m.cta.label, m.cta.href], ['decide ' + c.on_you + ' in a row', '/builders/street#deck']);
344
+ assert.deepEqual([m.cta.label, m.cta.href], ['decide ' + c.on_you + ' in a row', '#decide']);
345
345
  assert.equal(m.line, c.on_you + ' to do · ' + c.on_others + ' wait on others · 5 agents working');
346
346
  assert.equal(City.livingLine(1, 0, 1), '1 to do · 1 agent working');
347
347
  assert.equal(City.livingLine(0, 3, 0), 'nothing to do · 3 wait on others');
@@ -390,7 +390,7 @@ test('motion is CSS, long, inside the budget, with a still frame on every moving
390
390
 
391
391
  test('salmon is spent only on what waits on you (and the door, the focus ring)', () => {
392
392
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
393
- const ALLOWED = /city-win--you|city-pin--(urgent|needs)|city-pin__ring|city-pin__age|city-start|city-card__go--you|city-ask--you|city-plaque__tile\[data-key="you"\]|city-card__count--you|focus-visible|body\[data-page="city"\]\s*$/;
393
+ const ALLOWED = /city-win--you|city-pin--(urgent|needs)|city-pin__ring|city-pin__age|city-start|city-card__go--you|city-ask--you|city-plaque__tile\[data-key="you"\]|city-card__count--you|city-card--you|ask-crab|decide__yours|decide-ask--you|decide__tr--you|focus-visible|body\[data-page="city"\]\s*$/;
394
394
  let checked = 0;
395
395
  for (const r of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
396
396
  if (!/var\(--accent(-deep|-lift)?\)/.test(r[2])) continue;
@@ -507,7 +507,7 @@ async function bootLiving({ asks = false, answer = null } = {}) {
507
507
  vm.runInContext(read('city-draw.js'), ctx);
508
508
  assert.ok(window.GovLiving && window.GovCity);
509
509
  // the deck's card and sender, as city.html loads them (task 1004702)
510
- for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-deck.js']) vm.runInContext(read(f), ctx);
510
+ for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-deck.js', 'city-decide.js', 'city-switchboard.js']) vm.runInContext(read(f), ctx);
511
511
  assert.ok(window.GovDeck && window.GovDesk, 'the deck\'s scripts are on the page');
512
512
  vm.runInContext(read('city.js'), ctx);
513
513
  await window.GovCityPage.ready;
@@ -529,7 +529,7 @@ test('the page reads the living feeds and draws the sky, the plates, the plaque
529
529
  const m = city();
530
530
  const start = p.byId.get('cityStart');
531
531
  assert.equal(start.textContent, m.cta.label);
532
- assert.equal(start.attrs.href, '/builders/street#deck');
532
+ assert.equal(start.attrs.href, '#decide');
533
533
  assert.equal(p.byId.get('cityLine').textContent, m.line);
534
534
  for (const id of ['cityBlds', 'citySky', 'cityPins', 'cityPlaque']) assert.equal(findAll(p.byId.get(id), (n) => 'data-proto' in n.attrs).length, 0, id + ': nothing marked prototype');
535
535
  assert.doesNotMatch(read('city.html'), /cityKey|cityToast|data-proto/, 'the page has no prototype key and no in-place answer toast');
@@ -543,17 +543,38 @@ test('a building opens its card in place: who holds it, one fact, what waits, it
543
543
  assert.equal(card.attrs.hidden, undefined);
544
544
  assert.equal(card.attrs['data-building'], 'planning');
545
545
  assert.match(card.textContent, /Planning/);
546
- // the viewer (builder 2012) leads Planning: "Your seat." (task 1004702: the card speaks to you)
547
- assert.match(card.textContent, /Your seat\./);
546
+ // the viewer (builder 2012) leads Planning: "Your seat." rides the name as a hint, not a line
547
+ const nameEl = findAll(card, (x) => /\bcity-card__name\b/.test(String(x.className)))[0];
548
+ assert.equal(nameEl.attrs.title, 'Your seat.');
549
+ assert.doesNotMatch(card.textContent, /Your seat\./, 'no subtitle line');
548
550
  const n = civicOf(city(), 'planning').on_you;
549
- assert.match(card.textContent, new RegExp(n + ' wait on you\\.'));
551
+ const total = civicOf(city(), 'planning').open;
552
+ assert.match(card.textContent, new RegExp('Total ' + total + ' / Yours ' + n));
553
+ assert.ok(/\bcity-card--you\b/.test(card.className), 'a building on you wears the project colour');
554
+ assert.doesNotMatch(card.textContent, /Only you can answer/, 'the glow says it, not a line');
550
555
  assert.ok(/\bgov-glass\b/.test(card.className), 'the card is glass');
551
- const doors = findAll(card, (x) => x.tagName === 'a' && /city-card__(go|enter)/.test(String(x.className)));
552
- assert.deepEqual(doors.map((a) => [a.textContent, a.attrs.href]), [['decide all ' + n + ' in a row', '/builders/street#deck=planning'], ['go inside', '/builders/street#building=planning']], 'two doors');
556
+ assert.equal(findAll(card, (x) => x.tagName === 'a' && /city-card__enter/.test(String(x.className))).length, 0, 'no door to the street');
557
+ const go = findAll(card, (x) => /\bcity-card__go\b/.test(String(x.className)));
558
+ assert.deepEqual(go.map((b) => b.textContent), ['decide all ' + n], 'one decide button, in place');
559
+ assert.equal(findAll(card, (x) => /\bcity-card__arrow\b/.test(String(x.className))).length, 2, 'arrows to flip between buildings');
560
+ card.fire('keydown', { key: 'ArrowRight', preventDefault() {} });
561
+ assert.notEqual(p.byId.get('cityCard').attrs['data-building'], 'planning', 'the right arrow flips to the next building');
562
+ card.fire('keydown', { key: 'ArrowLeft', preventDefault() {} });
563
+ assert.equal(p.byId.get('cityCard').attrs['data-building'], 'planning', 'and the left arrow flips back');
553
564
  // the building's first ask stands in place, as the deck draws it
554
565
  const ask = findAll(card, (x) => /\bcity-ask\b/.test(String(x.className)));
555
566
  assert.equal(ask.length, 1, 'its first ask, in place');
556
567
  assert.ok(/\bask--here\b/.test(ask[0].className));
568
+ // answered in the card: the desk row's own acts are its answers, never "answer it on its desk"
569
+ assert.ok(findAll(card, (x) => /\bask__opt\b/.test(String(x.className))).length > 0, 'the card carries its answers');
570
+ assert.doesNotMatch(card.textContent, /Answer it on its desk/);
571
+ // Tab steps to the building's next item, Shift+Tab back
572
+ const stepN = () => findAll(p.byId.get('cityCard'), (x) => /\bcity-card__stepn\b/.test(String(x.className)))[0].textContent;
573
+ assert.equal(stepN(), '1 of ' + n);
574
+ p.byId.get('cityCard').fire('keydown', { key: 'Tab', preventDefault() {} });
575
+ assert.equal(stepN(), '2 of ' + n);
576
+ p.byId.get('cityCard').fire('keydown', { key: 'Tab', shiftKey: true, preventDefault() {} });
577
+ assert.equal(stepN(), '1 of ' + n);
557
578
  card.fire('keydown', { key: 'Escape', preventDefault() {} });
558
579
  assert.equal(card.attrs.hidden, '', 'Escape closes it');
559
580
  const again = p.byId.get('cityHits').children.find((b) => b.attrs['data-building'] === 'planning');
@@ -574,6 +595,10 @@ test('the city card answers its building\'s first ask in place, through the same
574
595
  const Kd = p.window.GovDeck;
575
596
  const op = opts[1];
576
597
  const key = op.attrs['data-key'];
598
+ // the first press picks that answer and sends nothing; the same number again confirms it
599
+ card.fire('keydown', { key: op.attrs['data-n'], preventDefault() {} });
600
+ await new Promise((r) => setTimeout(r, 20));
601
+ assert.equal(p.sent.length, 0, 'one press only picks');
577
602
  card.fire('keydown', { key: op.attrs['data-n'], preventDefault() {} });
578
603
  await new Promise((r) => setTimeout(r, 20));
579
604
  assert.equal(p.sent.length, 1, 'one request');
@@ -615,3 +640,84 @@ test('Planning stands behind nothing taller, so its lit floor shows', () => {
615
640
  for (const o of front) assert.ok(o.roof < planning.roof, 'planning stands behind the taller ' + o.key);
616
641
  assert.deepEqual(City.CIVIC_SLOTS.find((s) => s[0] === 'charter').slice(1), [5, 5], 'the Charter rises from the back corner');
617
642
  });
643
+
644
+ // ── the decide view and the switchboard, through the real page (task 1004784) ──
645
+
646
+ test('the decide view: one by one with Tab inside a building and arrows across, number keys pick then confirm', async () => {
647
+ const p = await bootLiving({ asks: true });
648
+ const d = p.window.GovCityPage.openDecide(null);
649
+ const view = p.byId.get('cityDecide');
650
+ assert.ok(view && view.attrs.hidden === undefined, 'the view opens over the city');
651
+ const pos = () => findAll(view, (x) => /\bdecide__pos-n\b/.test(String(x.className)))[0].textContent;
652
+ const first = pos();
653
+ const building = first.replace(/ \d+ of \d+$/, '');
654
+ view.fire('keydown', { key: 'ArrowRight', preventDefault() {}, stopPropagation() {} });
655
+ assert.notEqual(pos().replace(/ \d+ of \d+$/, ''), building, 'right goes to the next building');
656
+ view.fire('keydown', { key: 'ArrowLeft', preventDefault() {}, stopPropagation() {} });
657
+ assert.equal(pos(), first, 'left comes back to the first item of the first building');
658
+ const n = Number(/of (\d+)$/.exec(first)[1]);
659
+ if (n > 1) {
660
+ view.fire('keydown', { key: 'Tab', preventDefault() {}, stopPropagation() {} });
661
+ assert.equal(pos(), building + ' 2 of ' + n, 'Tab is the next item in the same building');
662
+ view.fire('keydown', { key: 'Tab', shiftKey: true, preventDefault() {}, stopPropagation() {} });
663
+ }
664
+ // walk to a card with answers, then pick and confirm
665
+ const live = () => { const o = findAll(view, (x) => /\bask__opt\b/.test(String(x.className))); return o.length && !/ask__opt--off/.test(o[0].className); };
666
+ const k = (key) => view.fire('keydown', { key, preventDefault() {}, stopPropagation() {} });
667
+ for (let b = 0; b < 12 && !live(); b++) { for (let i = 0; i < 30 && !live(); i++) k('Tab'); if (!live()) k('ArrowRight'); }
668
+ assert.ok(live(), 'some card answers in place');
669
+ const askId = () => (findAll(view, (x) => x.attrs && x.attrs['data-ask'])[0] || { attrs: {} }).attrs['data-ask'];
670
+ const was = askId();
671
+ const before = p.sent.length;
672
+ view.fire('keydown', { key: '1', preventDefault() {}, stopPropagation() {} });
673
+ await new Promise((r) => setTimeout(r, 10));
674
+ assert.equal(p.sent.length, before, 'the first press only picks');
675
+ assert.equal(askId(), was, 'and stays on the same card');
676
+ assert.deepEqual(JSON.parse(JSON.stringify(view.__armed)), { id: was, n: 1 }, 'the pick is held, waiting for its confirm');
677
+ view.fire('keydown', { key: '1', preventDefault() {}, stopPropagation() {} });
678
+ await new Promise((r) => setTimeout(r, 30));
679
+ assert.notEqual(askId(), was, 'the second press of the same number answers it, and the next card comes up');
680
+ assert.ok(d && d.isOpen());
681
+ });
682
+
683
+ test('the decide table: one row per open ask, and the bulk bar answers every ticked row through its own route', async () => {
684
+ const p = await bootLiving({ asks: true });
685
+ p.window.GovCityPage.openDecide(null, 'table');
686
+ const view = p.byId.get('cityDecide');
687
+ const rows = () => findAll(view, (x) => x.tagName === 'tr' && x.parentNode && x.parentNode.tagName === 'tbody');
688
+ const all = rows();
689
+ assert.ok(all.length > 1, 'rows are drawn');
690
+ const answerable = all.filter((r) => findAll(r, (x) => /\bdecide__opt\b/.test(String(x.className)) && !('disabled' in x.attrs)).length);
691
+ assert.ok(answerable.length >= 1, 'a row answers in place');
692
+ const box = findAll(answerable[0], (x) => /\bdecide__row-box\b/.test(String(x.className)))[0];
693
+ box.checked = true;
694
+ box.fire('change');
695
+ const bulk = findAll(view, (x) => /\bdecide__bulk-btn\b/.test(String(x.className)));
696
+ assert.ok(bulk.length >= 1, 'a ticked row offers its answers in the bulk bar');
697
+ const before = p.sent.length;
698
+ bulk[0].fire('click');
699
+ await new Promise((r) => setTimeout(r, 30));
700
+ assert.equal(p.sent.length, before + 1, 'one request per ticked row');
701
+ assert.match(p.sent[p.sent.length - 1].url, /^\/api\/bongos\//);
702
+ });
703
+
704
+ test('Automation opens as a switchboard through the page, and a flip sends the switch\'s own request', async () => {
705
+ const p = await bootLiving();
706
+ p.window.GovCityPage.openCard('automation');
707
+ const card = p.byId.get('cityCard');
708
+ assert.ok(/\bcity-card--board\b/.test(card.className), 'the card is a switchboard');
709
+ assert.equal(findAll(card, (x) => String(x.className).split(' ').includes('ask')).length, 0, 'no ask is drawn on it');
710
+ const toggles = findAll(card, (x) => /\bswb__toggle\b/.test(String(x.className)));
711
+ const none = findAll(card, (x) => /\bswb__none\b/.test(String(x.className)));
712
+ assert.ok(toggles.length || none.length, 'it draws the viewer\'s switches, or says they hold none');
713
+ if (toggles.length) {
714
+ const plain = toggles.find((t) => !findAll(t.parentNode.parentNode, (x) => /\bswb__why\b/.test(String(x.className))).length) || toggles[0];
715
+ const before = p.sent.length;
716
+ plain.fire('click');
717
+ await new Promise((r) => setTimeout(r, 20));
718
+ const sentNow = p.sent.length - before;
719
+ const opened = findAll(card, (x) => /\bswb__why\b/.test(String(x.className)) && !('hidden' in x.attrs)).length;
720
+ assert.ok(sentNow === 1 || opened === 1, 'a flip sends one request, or asks why first');
721
+ if (sentNow) assert.match(p.sent[p.sent.length - 1].url, /\/api\/bongos\/autonomy\//);
722
+ }
723
+ });
@@ -0,0 +1,51 @@
1
+ // tests/hall_city_rewording.mjs — a matter sent for a rewrite (the "?", owner
2
+ // 2026-10-23) leaves the to-do until its new words land, and is counted apart
3
+ // as "Rewording", building by building.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import vm from 'node:vm';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
13
+
14
+ function boot() {
15
+ const ctx = { console, document: {} };
16
+ ctx.window = ctx;
17
+ vm.createContext(ctx);
18
+ vm.runInContext(fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8'), ctx);
19
+ return ctx.window.GovCityDecide;
20
+ }
21
+ const DOCKET = { matters: [
22
+ { id: 'blockers:1', building: 'planning', unclear: { times: 1 } },
23
+ { id: 'blockers:2', building: 'planning' },
24
+ { id: 'redteam_reports:3', building: 'disputes', unclear: { times: 2 } },
25
+ ] };
26
+
27
+ test('a flagged matter is left out of the queue as if answered, and the answered stay out too', () => {
28
+ const G = boot();
29
+ const done = JSON.parse(JSON.stringify(G.withoutReworded({ 'blockers:9': true }, DOCKET)));
30
+ assert.deepEqual(Object.keys(done).sort(), ['blockers:1', 'blockers:9', 'redteam_reports:3']);
31
+ assert.equal(done['blockers:2'], undefined, 'an unflagged matter stays in the queue');
32
+ });
33
+
34
+ test('Rewording counts per building, and everywhere', () => {
35
+ const G = boot();
36
+ assert.equal(G.rewordingCount(DOCKET, 'planning'), 1);
37
+ assert.equal(G.rewordingCount(DOCKET, 'disputes'), 1);
38
+ assert.equal(G.rewordingCount(DOCKET, 'treasury'), 0);
39
+ assert.equal(G.rewordingCount(DOCKET, null), 2);
40
+ assert.equal(G.rewordingCount({ matters: [] }, null), 0);
41
+ });
42
+
43
+ test('the "?" asks before it sends: the send lives behind a confirm button', () => {
44
+ const src = fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8');
45
+ const fn = src.split('function unclearButton')[1].split('\n }\n')[0];
46
+ const send = fn.indexOf("on.send({ method: 'POST'");
47
+ const confirm = fn.indexOf("go.addEventListener('click'");
48
+ assert.ok(send > 0 && confirm > 0 && send > confirm, 'the request is sent only from the confirm button');
49
+ assert.match(fn, /Send this to Claude to rewrite\?/);
50
+ assert.match(fn, /Nothing is decided or changed/);
51
+ });
@@ -0,0 +1,63 @@
1
+ // tests/hall_city_switchboard.mjs — Automation as a switchboard (owner, 2026-10-23).
2
+ // The board draws only the switches the docket hands THIS viewer, every flip
3
+ // sends the desk row's own request, and the fence is never a to-do.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import vm from 'node:vm';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
14
+ const require = createRequire(import.meta.url);
15
+ const autoDocket = require(path.join(ROOT, 'modules/autonomy/governor-docket.js'));
16
+ const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/government__docket.json'), 'utf8'));
17
+ const DOCKET = FIXTURE.body || FIXTURE;
18
+
19
+ function boot() {
20
+ const ctx = { console };
21
+ ctx.window = ctx;
22
+ vm.createContext(ctx);
23
+ for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'city-switchboard.js']) vm.runInContext(fs.readFileSync(path.join(PUB, f), 'utf8'), ctx);
24
+ return ctx;
25
+ }
26
+ const j = (v) => JSON.parse(JSON.stringify(v));
27
+ const GOALS = [{ id: '1000089', title: 'Working area 3', status: 'open' }];
28
+
29
+ test('the fence is a standing state, never a to-do', () => {
30
+ for (const mayManage of [true, false]) {
31
+ for (const p of [{ enabled: false, goals: [] }, { enabled: true, goals: [] }]) {
32
+ assert.equal(autoDocket.fenceDocketMatters(p, mayManage)[0].state, 'settled');
33
+ }
34
+ }
35
+ assert.deepEqual(autoDocket.switchDeskRows({ enabled: false, goals: [] })[0].acts, [{ key: 'switch_on' }], 'off: the master row switches it on');
36
+ assert.equal(autoDocket.switchDeskRows({ enabled: true, goals: [] })[0].acts[0].key, 'switch_off', 'on, even with no goal: the master row switches it off');
37
+ });
38
+
39
+ test('the board draws the viewer\'s switches, and each flip is the desk row\'s request', () => {
40
+ const w = boot();
41
+ const m = j(w.GovCitySwitchboard.model(DOCKET, { now: Date.parse('2026-10-09T15:00:00Z'), goals: GOALS }));
42
+ assert.deepEqual(m.groups.map((g) => g.title), ['AI builds on its own (Autobongos)', 'Goals the AI may work on', 'Computers doing the building']);
43
+ const master = m.groups[0].switches[0];
44
+ assert.equal(master.on, false);
45
+ const live = w.GovCitySwitchboard.model(DOCKET, { now: Date.parse('2026-10-09T15:00:00Z'), goals: GOALS });
46
+ const flip = live.groups[0].switches[0].flip;
47
+ assert.deepEqual(j(w.GovMatterCard.request(flip.row.card, flip.key, flip.input)), { method: 'POST', url: '/api/bongos/autonomy/fence', body: { enabled: true } });
48
+ const goal = live.groups[1].switches[0];
49
+ assert.equal(goal.on, false, 'an open goal not yet allowed is an off switch');
50
+ assert.deepEqual(j(w.GovMatterCard.request(goal.flip.row.card, goal.flip.key, goal.flip.input)).body, { goal_id: 1000089 });
51
+ const runner = live.groups[2].switches[0];
52
+ assert.equal(runner.on, true);
53
+ assert.ok(runner.flip.reasons.length > 0, 'pausing a runner asks why, in one tap');
54
+ });
55
+
56
+ test('a viewer handed no Automation desks gets no switches', () => {
57
+ const w = boot();
58
+ const bare = j(DOCKET);
59
+ bare.desks = bare.desks.filter((d) => !/^automation\./.test(d.key));
60
+ const m = j(w.GovCitySwitchboard.model(bare, { goals: GOALS }));
61
+ assert.equal(m.any, false);
62
+ assert.deepEqual(m.groups, []);
63
+ });
@@ -555,9 +555,9 @@ test('a Lead the government appointed stands on their building on a real site; e
555
555
  assert.match(hit('planning').attrs['aria-label'], new RegExp('led by ' + A_REAL_LEAD));
556
556
  assert.match(hit('release').attrs['aria-label'], /held by the owner/);
557
557
  hit('planning').fire('click');
558
- assert.match(city.byId.get('cityCard').textContent, new RegExp('Led by ' + A_REAL_LEAD + '\\.'));
558
+ assert.match(city.byId.get('cityCard').children[0].children[0].attrs.title, new RegExp('Led by ' + A_REAL_LEAD + '\\.'));
559
559
  hit('treasury').fire('click');
560
- assert.match(city.byId.get('cityCard').textContent, /hold(s)? it until a Lead is seated\./);
560
+ assert.match(city.byId.get('cityCard').children[0].children[0].attrs.title, /hold(s)? it until a Lead is seated\./);
561
561
  noPrototype(city, 'city card with real Leads', { seatsRead: true });
562
562
  const street = await bootStreet('', false);
563
563
  assert.ok(street.calls.includes('/api/bongos/government/leads'), 'the street reads it too');
@@ -141,7 +141,6 @@ test('a real answer is the desk row\'s own request, to the route the desk uses',
141
141
  'redteam_reports:confirm': 'POST /api/bongos/security/reports/N/confirm',
142
142
  'redteam_reports:dispute': 'POST /api/bongos/security/reports/N/dispute',
143
143
  'override_requests:release': 'POST /api/bongos/override-requests/N/decide',
144
- 'autobongos_fence:switch_on': 'POST /api/bongos/autonomy/fence',
145
144
  'treasury_spending:answer': 'POST /api/bongos/government/treasury/borrows/N/answer',
146
145
  });
147
146
  // a blocker's option is its own chip, so its words are the resolution note
@@ -151,8 +150,11 @@ test('a real answer is the desk row\'s own request, to the route the desk uses',
151
150
  const cr = K.cardView(lv.city.asks.find((x) => x.id === 'criteria_uat:1000184'), c);
152
151
  assert.deepEqual(cr.options.map((o) => o.plan.mode), ['record', 'send', 'note']);
153
152
  assert.equal(cr.options[0].plan.id, '1000184');
154
- const fence = K.cardView(lv.city.asks.find((x) => x.id === 'autobongos_fence:fence'), c);
155
- assert.deepEqual(fence.options.map((o) => o.plan.mode), ['send', 'note']);
153
+ // the fence is a standing state on the Automation switchboard, never a to-do
154
+ // (task 1004784): its matter is settled and carries no act, so the switch is
155
+ // flipped from the switchboard's desk row, not from a card
156
+ const fence = lv.city.asks.find((x) => x.id === 'autobongos_fence:fence');
157
+ assert.ok(!fence || fence.state !== 'needs', 'the fence never waits on anyone');
156
158
  });
157
159
 
158
160
  test('a viewer the server hands no acts gets answers marked not from here, never a send', () => {
@@ -492,7 +494,7 @@ test('the page: #deck draws the top card over the street; a key answers it throu
492
494
  assert.equal(next.getAttribute('data-ask'), 'blockers:1000143');
493
495
  assert.match(next.className, /\bask--rise\b/, 'the next one rises');
494
496
  assert.equal(p.cls(p.deck(), 'deck-dock__of')[0].textContent, ' of 8 to do');
495
- assert.equal(p.byId.get('hudNeeds').textContent, '37', 'the street\'s header moves with the deck');
497
+ assert.equal(p.byId.get('hudNeeds').textContent, '36', 'the street\'s header moves with the deck');
496
498
  });
497
499
 
498
500
  test('the page: a refusal keeps the card and counts nothing; S skips to the back', async () => {
@@ -276,7 +276,7 @@ test('what needs you lights salmon windows on the floor that holds it, with a ri
276
276
  assert.ok(d.windows.some((w) => w.building === 'planning'), 'the viewer leads Planning');
277
277
  assert.ok(!d.windows.some((w) => ['review', 'release', 'treasury'].includes(w.building)), 'what waits on another Lead lights nothing');
278
278
  // the hub as it is (no Lead read, everything on the owner seat): every docket need is the viewer's own
279
- assert.deepEqual(street({ leads: null, docket: fx('government__docket') }).depth.windows.map((w) => w.key), ['needs:automation:0', 'needs:disputes:0', 'needs:planning:2', 'needs:release:0', 'needs:review:1', 'needs:treasury:4']);
279
+ assert.deepEqual(street({ leads: null, docket: fx('government__docket') }).depth.windows.map((w) => w.key), ['needs:disputes:0', 'needs:planning:2', 'needs:release:0', 'needs:review:1', 'needs:treasury:4']);
280
280
  // moving and settled light nothing; nor does a source no floor holds
281
281
  const doc2 = clone(doc);
282
282
  doc2.matters.forEach((m) => { m.state = 'moving'; });
@@ -404,7 +404,8 @@ function actedDocket({ longWords = false, force = true } = {}) {
404
404
  desk('people.builders', 'people', buildersRoute.builderDeskRows([{ id: 7, display_name: t('Ana Ruiz'), github_login: 'ana', rank: 'thetes' }, { id: 95, display_name: 'Me', rank: 'archon' }], { viewerId: '95', mayRank: true, mayStop: true, ranks: LADDER })),
405
405
  desk('people.ranks', 'people', govFloors.rankDeskRows([{ rank_key: 'tester', label: t('Tester'), is_system: false }, { rank_key: 'archon', label: 'Archon', is_system: true }], [{ builder_id: 7, display_name: 'Ana Ruiz', rank_key: 'tester' }, { builder_id: 8, github_login: 'kai', rank_key: 'metic' }])),
406
406
  desk('disputes.confirmed', 'disputes', [reportsRoute.confirmedDeskRow({ id: 3, title: t('token leak'), severity: 'low', confirmed_at: '2026-09-28T10:00:00.000Z' })]),
407
- desk('automation.switch', 'automation', autoDocket.switchDeskRows(project)),
407
+ // the switch is a standing row in either position (the fence matter is settled, never a to-do)
408
+ desk('automation.switch', 'automation', autoDocket.switchDeskRows({ enabled: false, paused_reason: 'payments touched', goals: [] }).map((r) => ({ ...r, id: 'switch-off' })).concat(autoDocket.switchDeskRows(project))),
408
409
  desk('automation.goals', 'automation', autoDocket.goalDeskRows(project)),
409
410
  desk('automation.runners', 'automation', autoDocket.runnerDeskRows([{ builder_id: 7, display_name: t('Ana Ruiz'), runners: [{ working_task_id: 12 }] }, { builder_id: 8, owner_paused: true, owner_paused_reason: 'spending too much', runners: [] }], NOW, healthy)),
410
411
  ];
@@ -469,10 +470,11 @@ test('Disputes: a report is confirmed or disputed, a confirmed one marked fixed,
469
470
  test('Automation: switch on and off, allow a goal from the open goals, first in line, disallow, pause and resume a runner', () => {
470
471
  const dk = actedDocket();
471
472
  const sw = actedRows(dk, 'automation', 0);
472
- const off = sw.find((r) => r.source === 'autobongos_fence');
473
+ assert.ok(!sw.some((r) => r.source === 'autobongos_fence'), 'the switch is not a to-do: its matter is settled');
474
+ const off = sw.find((r) => r.actions.some((a) => a.key === 'switch_on'));
473
475
  assert.deepEqual(off.actions.map((a) => a.label), ['switch on']);
474
476
  assert.deepEqual(j(C.request(off.card, 'switch_on')), { method: 'POST', url: '/api/bongos/autonomy/fence', body: { enabled: true } });
475
- const on = sw.find((r) => r.desk);
477
+ const on = sw.find((r) => r.actions.some((a) => a.key === 'switch_off'));
476
478
  assert.deepEqual(j(C.request(on.card, 'switch_off', on.choices.switch_off.options[0].input)).body, { enabled: false, reason: 'pause for now' });
477
479
  const goals = actedRows(dk, 'automation', 1);
478
480
  const first = goals.find((r) => r.name === 'Governor city');
@@ -805,6 +805,32 @@ try {
805
805
  assert.equal(b.el('tw-frame').hidden, true);
806
806
  });
807
807
 
808
+ // task 1004776: a guarded line shows what it must still say under it, and the
809
+ // note turns to a warning while the artist types words that drop it.
810
+ await test('a guarded line shows its must-still-say note, and warns as the artist types it away', async () => {
811
+ const guarded = require('../modules/copy-desk/guarded-lines.js');
812
+ guarded._setList({ lines: [{ page_id: 'landing:index', file: 'modules/public-landing/public/index.html', must_contain: ['ship the code'], must_still_say: 'Agents ship the code.' }] });
813
+ try {
814
+ const ed = editorRead({ draftLines: null });
815
+ const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(ed) } });
816
+ await b.settle();
817
+ assert.equal(b.el('tw-L0004-note').textContent, 'must still say: Agents ship the code.');
818
+ assert.match(b.el('tw-L0004-note').className, /tw-note--guarded/);
819
+ b.type('L0004', 'Agents claim the tasks.');
820
+ assert.equal(b.el('tw-L0004-note').textContent, 'this no longer says what it must · must still say: Agents ship the code.');
821
+ b.type('L0004', 'Our agents ship the code.');
822
+ assert.equal(b.el('tw-L0004-note').textContent, 'must still say: Agents ship the code.');
823
+ } finally {
824
+ guarded._setList(null);
825
+ }
826
+ });
827
+
828
+ await test('the submit warning is plain words the editor shows after sending', () => {
829
+ assert.deepEqual(lib.submitWarningHtml([{ key: 'L0004', must_still_say: 'Agents ship the code.' }]),
830
+ ['A line you changed no longer says what it must: Agents ship the code. It was sent anyway; the person approving it will see this.']);
831
+ assert.deepEqual(lib.submitWarningHtml(undefined), []);
832
+ });
833
+
808
834
  await test('the page writes only the copy-desk routes it names', () => {
809
835
  const js = read('tweak-editor.js');
810
836
  const writes = [...js.matchAll(/api\.request\('(POST|PUT|PATCH|DELETE)', ([^,]+),/g)].map((m) => `${m[1]} ${m[2]}`);
@@ -0,0 +1,112 @@
1
+ // tests/module_channel_install.mjs — install and update honour the channel a
2
+ // project is on (task 1003805, ADR 0344 D1/D2, ADR 0338 D2).
3
+ //
4
+ // WHY. The channels exist (core_276) and new versions land on tester (task
5
+ // 1003803), but until acquire reads them, every project got the newest version
6
+ // whatever its channel. These pin the rule: a tester project gets the newest on
7
+ // tester or general, everyone else the newest on general, nobody a withdrawn one,
8
+ // and `bongos upgrade` reports the same version update would hand over. Run
9
+ // against a fake pg client; the routes are pinned in their source.
10
+
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const { servedChannels, resolveAcquirable, latestVersions } = require('../src/bongos/module-store.js');
21
+ const { testsVersionsOf } = require('../src/bongos/module-testing.js');
22
+
23
+ const row = (version, channel) => ({ id: 1, module_key: 'weather', version, channel, core_version: '^1.0.0', tarball_sha256: 'a'.repeat(64), tree_sha256: 'b'.repeat(64), tarball_bytes: 10, artifact_path: `weather/weather-${version}.tgz` });
24
+
25
+ function fakeDb(versions) {
26
+ return {
27
+ async query(sql) {
28
+ if (sql.includes('FROM store_modules ')) return { rows: [{ module_key: 'weather', status: 'listed' }] };
29
+ if (sql.includes('FROM store_module_versions')) return { rows: versions };
30
+ if (sql.includes('FROM store_module_prices')) return { rows: [] };
31
+ throw new Error(`unhandled: ${sql}`);
32
+ },
33
+ };
34
+ }
35
+ const SHELF = [row('1.0.0', 'general'), row('1.1.0', 'general'), row('1.2.0', 'tester'), row('2.0.0', 'withdrawn')];
36
+ const GENERAL = servedChannels(false);
37
+ const TESTER = servedChannels(true);
38
+
39
+ test('a tester project takes tester and general versions, everyone else general, nobody withdrawn', () => {
40
+ assert.deepEqual(TESTER, ['tester', 'general']);
41
+ assert.deepEqual(GENERAL, ['general']);
42
+ for (const c of [TESTER, GENERAL]) assert.ok(!c.includes('withdrawn'));
43
+ });
44
+
45
+ test('"general only" keeps a tester project on general for that one module', () => {
46
+ const testing = { tester: true, general_only: ['weather'] };
47
+ assert.deepEqual(servedChannels(testsVersionsOf(testing, 'weather')), GENERAL);
48
+ assert.deepEqual(servedChannels(testsVersionsOf(testing, 'clock')), TESTER);
49
+ assert.deepEqual(servedChannels(testsVersionsOf(null, 'clock')), GENERAL, 'an unreadable choice is not a tester');
50
+ });
51
+
52
+ test('with no version asked for, each project gets the newest on its own channels', async () => {
53
+ const general = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb(SHELF) });
54
+ assert.equal(general.version.version, '1.1.0');
55
+ assert.equal(general.version.channel, 'general');
56
+ const tester = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb(SHELF) });
57
+ assert.equal(tester.version.version, '1.2.0', 'the newer withdrawn 2.0.0 is never handed out');
58
+ assert.equal(tester.version.channel, 'tester');
59
+ });
60
+
61
+ test('an exact version off the project\'s channels is refused, never handed over', async () => {
62
+ const early = await resolveAcquirable('weather', { version: '1.2.0', channels: GENERAL, db: fakeDb(SHELF) });
63
+ assert.equal(early.ok, false);
64
+ assert.equal(early.code, 'version_not_on_channel');
65
+ assert.match(early.message, /testers/);
66
+ const gone = await resolveAcquirable('weather', { version: '2.0.0', channels: TESTER, db: fakeDb(SHELF) });
67
+ assert.equal(gone.code, 'version_not_on_channel');
68
+ assert.match(gone.message, /withdrawn/);
69
+ assert.equal((await resolveAcquirable('weather', { version: '1.0.0', channels: GENERAL, db: fakeDb(SHELF) })).ok, true);
70
+ });
71
+
72
+ test('a module whose only version is still with testers has nothing for a general project yet', async () => {
73
+ const r = await resolveAcquirable('weather', { channels: GENERAL, db: fakeDb([row('1.0.0', 'tester')]) });
74
+ assert.equal(r.code, 'no_versions_on_channel');
75
+ assert.equal(r.status, 404);
76
+ assert.match(r.message, /testers/);
77
+ const gone = await resolveAcquirable('weather', { channels: TESTER, db: fakeDb([row('1.0.0', 'withdrawn')]) });
78
+ assert.equal(gone.code, 'no_versions_on_channel');
79
+ assert.match(gone.message, /withdrawn/);
80
+ });
81
+
82
+ test('no channels means no channel check — recording a version already placed', async () => {
83
+ const r = await resolveAcquirable('weather', { version: '2.0.0', db: fakeDb(SHELF) });
84
+ assert.equal(r.ok, true);
85
+ });
86
+
87
+ test('upgrade reports the newest version on the channels this project takes', async () => {
88
+ const db = { async query() { return { rows: SHELF.map((r) => ({ ...r, status: 'listed' })) }; } };
89
+ const general = await latestVersions(['weather'], { db, channelsFor: () => GENERAL });
90
+ assert.equal(general.weather.version, '1.1.0');
91
+ const tester = await latestVersions(['weather'], { db, channelsFor: () => TESTER });
92
+ assert.equal(tester.weather.version, '1.2.0');
93
+ assert.equal(tester.weather.channel, 'tester');
94
+ const none = await latestVersions(['weather'], { db, channelsFor: () => ['nothing'] });
95
+ assert.deepEqual(none, {});
96
+ });
97
+
98
+ test('the acquire, download and latest routes ask the project\'s channels; recording does not', () => {
99
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
100
+ const route = (decl) => {
101
+ const start = src.indexOf(decl);
102
+ assert.ok(start !== -1, decl);
103
+ return src.slice(start, src.indexOf('}));', start));
104
+ };
105
+ const acquire = route("router.post('/store/modules/:key/acquire'");
106
+ assert.match(acquire, /resolveAcquirable\(key, \{ version: wanted \|\| null, channels: \(await projectChannels\(\)\)\(key\) \}\)/);
107
+ assert.match(acquire, /channel: v\.channel,/);
108
+ assert.doesNotMatch(acquire, /channel: 'general'/);
109
+ assert.match(route("router.get('/store/modules/:key/versions/:version/tarball'"), /channels: \(await projectChannels\(\)\)\(key\)/);
110
+ assert.match(route("router.get('/store/modules/latest'"), /latestVersions\(keys, \{ channelsFor: await projectChannels\(\) \}\)/);
111
+ assert.doesNotMatch(route("router.post('/store/modules/:key/acquired'"), /channels/);
112
+ });