@bongos/core 1.19.234 → 1.19.236

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.234",
6
- "core_contract": "1.19.234",
7
- "source_commit": "e80285bcecac511d018c7d39b58a2ca05e6fc5a2",
5
+ "core_version": "1.19.236",
6
+ "core_contract": "1.19.236",
7
+ "source_commit": "0a9c519e8b99e0d694e966d655d9b8a2a8f0eb3b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T16:39:12.790Z",
9
+ "built_at": "2026-08-18T16:55:12.932Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1318,
14
+ "functional_verbatim": 1320,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1650,
20
- "tree_sha256": "c706960c4712d454c74aca743c2612907744dcf50f641f8c9ee685e23db42f37",
19
+ "file_count": 1652,
20
+ "tree_sha256": "7fe9b6616b232257cbe77dced905478c770425991cbc992686b266c61f984739",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2902,7 +2902,7 @@
2902
2902
  {
2903
2903
  "path": "modules/government/board.js",
2904
2904
  "mode": "0000644",
2905
- "sha256": "34a041cb7d001b71109b8243cfd2d34dd4185da4656d6eb31ed8af113490fd6b"
2905
+ "sha256": "d9f0a9fa68180b7aad47e68edcf0859598d03bf2688bbd64ab286b7facd4d8af"
2906
2906
  },
2907
2907
  {
2908
2908
  "path": "modules/government/catalog.js",
@@ -2917,7 +2917,7 @@
2917
2917
  {
2918
2918
  "path": "modules/government/db.js",
2919
2919
  "mode": "0000644",
2920
- "sha256": "11f06bb6eb105a7f52a5a6aeb94356a3cb975f82b1e4ed381bacef82463feba2"
2920
+ "sha256": "d50b12547863df08cf32c606fe91187e1239f5b0d339f9d34ed9f3b5403b3d15"
2921
2921
  },
2922
2922
  {
2923
2923
  "path": "modules/government/migrations/governance_001_tables.sql",
@@ -3272,12 +3272,12 @@
3272
3272
  {
3273
3273
  "path": "modules/hall-ui/public/government.html",
3274
3274
  "mode": "0000644",
3275
- "sha256": "a76e7263c87178427061ff6b02588e524ca885b3964e54f693e4fc88c9ef5c26"
3275
+ "sha256": "b21f881ddb943b46a82a2e613485f451b84dd763f73d3d4cedf7afcf66982d30"
3276
3276
  },
3277
3277
  {
3278
3278
  "path": "modules/hall-ui/public/government.js",
3279
3279
  "mode": "0000644",
3280
- "sha256": "7d6ce981e085a0a5d3fed711088720c412a82b54c4c2b9f436f1e112b453c2d3"
3280
+ "sha256": "246a14606c8d7b2d58d4ecb7afb9ed0f8a9d543f8c40d85e321a169ef9750fcd"
3281
3281
  },
3282
3282
  {
3283
3283
  "path": "modules/hall-ui/public/hall-kit.js",
@@ -4322,12 +4322,12 @@
4322
4322
  {
4323
4323
  "path": "package-lock.json",
4324
4324
  "mode": "0000644",
4325
- "sha256": "f08d3eab8dc3b1ef943b3ef19523e21c7676ba91e57f1791ce984b7847c27078"
4325
+ "sha256": "f5abc3db63111a1a16cbe3d1dab016100dd4fd2327c515bfca2aeecad46d4fe9"
4326
4326
  },
4327
4327
  {
4328
4328
  "path": "package.json",
4329
4329
  "mode": "0000644",
4330
- "sha256": "fe66476a7d77fe1f6ddaf04090e1af767696443fab948f0fc64713ed48c64b5d"
4330
+ "sha256": "868413f24489f75a2b26faab9a4a60fb25f5348c514aa73895a074da1f15f0e4"
4331
4331
  },
4332
4332
  {
4333
4333
  "path": "public-docs/index.html",
@@ -5942,7 +5942,7 @@
5942
5942
  {
5943
5943
  "path": "src/module-api.js",
5944
5944
  "mode": "0000644",
5945
- "sha256": "509de05d397e1c27f123e67e666147efa117ce28e98ef00f1386687ca15a4097"
5945
+ "sha256": "052bbf66213891c419bee8571c5140474a9544fb3a21b8cc0aa12dacaf05a2b9"
5946
5946
  },
5947
5947
  {
5948
5948
  "path": "src/module-loader/catalog.js",
@@ -6887,13 +6887,18 @@
6887
6887
  {
6888
6888
  "path": "tests/government_board_view.mjs",
6889
6889
  "mode": "0000644",
6890
- "sha256": "104dbfd5510f4abf4580cf89f57e4be15667b4b6e6f171a2635786bfd4d65f9e"
6890
+ "sha256": "827d743080201f11b2282aaa77d37149aaddaddc42b2b444c260a2696a7727ca"
6891
6891
  },
6892
6892
  {
6893
6893
  "path": "tests/government_board_vote.mjs",
6894
6894
  "mode": "0000644",
6895
6895
  "sha256": "c1b9b0963dde0c033bd0eb411b63032a51edaa3d91751100593b7e3b4999c017"
6896
6896
  },
6897
+ {
6898
+ "path": "tests/government_board_vote_ui.mjs",
6899
+ "mode": "0000644",
6900
+ "sha256": "a9e46b10cf9b6c17550f22463d13caa0d4b63d87d4fc63d087dd14e4b95b8312"
6901
+ },
6897
6902
  {
6898
6903
  "path": "tests/government_board_window.mjs",
6899
6904
  "mode": "0000644",
@@ -6904,6 +6909,11 @@
6904
6909
  "mode": "0000644",
6905
6910
  "sha256": "74cfbd482b689df40348af3bdaf050f056e216ff18fdd6add4136d4589b7cd92"
6906
6911
  },
6912
+ {
6913
+ "path": "tests/government_charter_library.mjs",
6914
+ "mode": "0000644",
6915
+ "sha256": "9a52ac8fbbc9e7b0fc4b45841b9dbf831615e5d64eb1e3f0d9c446fc70552629"
6916
+ },
6907
6917
  {
6908
6918
  "path": "tests/government_claim_eligibility.mjs",
6909
6919
  "mode": "0000644",
@@ -6917,7 +6927,7 @@
6917
6927
  {
6918
6928
  "path": "tests/government_constitution_view.mjs",
6919
6929
  "mode": "0000644",
6920
- "sha256": "daf26af68fcaa19a6de3793e0c9ab81b2981d8ed5e4d5ec5f860c686ac7610fb"
6930
+ "sha256": "64fc8006d5b3a562b01a38bfd0ad00a1357969b0ac52ce270e77d4d93eddca80"
6921
6931
  },
6922
6932
  {
6923
6933
  "path": "tests/government_fold.mjs",
@@ -499,7 +499,17 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
499
499
  }
500
500
  const project = (item) => {
501
501
  const subject = item.subject_type === 'full_idea'
502
- ? (() => { const r = ideaById.get(String(item.subject_id)); return r ? { title: r.title, author_id: String(r.captured_by) } : null; })()
502
+ ? (() => {
503
+ const r = ideaById.get(String(item.subject_id));
504
+ return r ? {
505
+ title: r.title,
506
+ author_id: String(r.captured_by),
507
+ // R17: the voter decides with the idea in front of them.
508
+ field_values: r.field_values || null,
509
+ template_key: r.template_key || null,
510
+ completeness_score: r.completeness_score ?? null,
511
+ } : null;
512
+ })()
503
513
  : (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
504
514
  return {
505
515
  id: String(item.id),
@@ -530,9 +540,30 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
530
540
  // row carries the rule it was DECIDED UNDER (the item's snapshot), so the
531
541
  // reader can verify every change was made legally by the constitution of its
532
542
  // day — the §7 chain, auditable end to end.
543
+ // ── the charter ladder's recommendation (BV1.R19, criterion C5) ─────────────
544
+ //
545
+ // The developmental ladder from the Six Variables synthesis (Part III), as the
546
+ // spec's task text records it: formality is adopted only where informal
547
+ // coordination breaks. PURE and server-side so R26's proof can hold the
548
+ // recommendation against the real headcount — the library's one live datum.
549
+ // The bands deliberately track only the thresholds the synthesis NAMES
550
+ // (~9 name an executive, ~50 written charter + council, ~150 adjudication
551
+ // separates / federate beyond); everything else is reference prose, not code.
552
+ function recommendedCharter(headcount) {
553
+ const n = Number(headcount) || 0;
554
+ if (n <= 2) return { key: 'monarchy', rung: 'solo/pair', why: 'informal coordination has nothing to break yet — the founding monarchy is the honest shape' };
555
+ if (n <= 8) return { key: 'direct-democracy', rung: 'circle (3–8)', why: 'a consent circle of all trusted members — the rung this ladder was written on' };
556
+ if (n <= 49) return { key: 'presidential', rung: '~9: name an executive', why: 'past ~9, naming an executive beats everyone coordinating with everyone' };
557
+ if (n <= 149) return { key: 'foundation', rung: '~50: written charter + council', why: 'past ~50, the charter must be written down and a council carries it' };
558
+ return { key: 'federation', rung: '~150: adjudication separates — federate beyond', why: 'past ~150, one body cannot both govern and adjudicate; federate' };
559
+ }
560
+
533
561
  async function constitutionView({ historyLimit = 50 } = {}) {
534
562
  const { board: live } = loadGovernmentConfig();
535
- const rows = await db.listAmendmentHistory(historyLimit);
563
+ const [rows, headcount] = await Promise.all([
564
+ db.listAmendmentHistory(historyLimit),
565
+ db.countActiveBuilders(),
566
+ ]);
536
567
  const history = rows.map((r) => ({
537
568
  item_id: String(r.item_id),
538
569
  amendment_id: String(r.amendment_id),
@@ -546,7 +577,7 @@ async function constitutionView({ historyLimit = 50 } = {}) {
546
577
  outcome: r.outcome,
547
578
  decided_under: r.decided_under || null,
548
579
  }));
549
- return { board: live, history };
580
+ return { board: live, history, headcount, recommended: recommendedCharter(headcount) };
550
581
  }
551
582
 
552
583
  // ── R13's read: the feedback a returned idea carries back to its author ─────
@@ -591,4 +622,5 @@ module.exports = {
591
622
  proposeAmendment,
592
623
  boardRoomView,
593
624
  constitutionView,
625
+ recommendedCharter,
594
626
  };
@@ -493,8 +493,15 @@ async function getVotesForItems(itemIds) {
493
493
  // an amendment its proposed block + rationale.
494
494
  async function getIdeaSubjects(ideaIds) {
495
495
  if (!ideaIds || !ideaIds.length) return [];
496
+ // The five sections ride along (R17): the full idea must be IN FRONT of the
497
+ // voter while they decide — that context is the entire reason voting happens
498
+ // in the hall rather than on a reaction. LEFT JOIN: an idea whose grade row
499
+ // is somehow gone still shows its title.
496
500
  const { rows } = await pool.query(
497
- `SELECT id, title, captured_by FROM idea_inbox WHERE id = ANY($1::bigint[])`,
501
+ `SELECT i.id, i.title, i.captured_by, g.field_values, g.template_key, g.completeness_score
502
+ FROM idea_inbox i
503
+ LEFT JOIN ideas_grades g ON g.idea_id = i.id
504
+ WHERE i.id = ANY($1::bigint[])`,
498
505
  [ideaIds],
499
506
  );
500
507
  return rows;
@@ -509,6 +516,17 @@ async function getAmendmentSubjects(amendmentIds) {
509
516
  return rows;
510
517
  }
511
518
 
519
+ // The live headcount (R19): active humans, the same two filters as board
520
+ // membership — a machine principal is not a team member, and neither is a
521
+ // departed builder. Feeds the charter library's "recommended at YOUR headcount"
522
+ // line, which R26's proof holds against this exact number.
523
+ async function countActiveBuilders() {
524
+ const { rows } = await pool.query(
525
+ `SELECT count(*)::int AS n FROM builders WHERE status = 'active' AND system_role IS NULL`,
526
+ );
527
+ return rows[0] ? rows[0].n : 0;
528
+ }
529
+
512
530
  // The constitution's change HISTORY (R18): every CLOSED amendment sitting —
513
531
  // passed AND returned — joined to what was proposed, newest first. Returned
514
532
  // proposals are deliberately part of the record: "a switch can never be flipped
@@ -653,4 +671,5 @@ module.exports = {
653
671
  getIdeaSubjects,
654
672
  getAmendmentSubjects,
655
673
  listAmendmentHistory,
674
+ countActiveBuilders,
656
675
  };
@@ -99,6 +99,6 @@
99
99
  <script src="/builders/palette.js" defer></script>
100
100
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
101
101
  <script src="/builders/hall-kit.js"></script>
102
- <script src="/builders/government.js?v=2026-08-18-constitution"></script>
102
+ <script src="/builders/government.js?v=2026-08-18-vote-form"></script>
103
103
  </body>
104
104
  </html>
@@ -80,6 +80,17 @@
80
80
  unknown_permission: 'The permission catalog does not define that key.',
81
81
  bad_rank_key: 'Rank keys are kebab-case: lowercase letters, digits, dashes.',
82
82
  validation_failed: 'Missing or invalid fields.',
83
+ // the vote refusals (R17 over R08's route)
84
+ not_board_member: 'Only members of this item\u2019s board may vote on it \u2014 membership follows the rules that were in force when the window opened.',
85
+ anti_self_vote: 'You can\u2019t vote on your own idea \u2014 that\u2019s the line that keeps ratification honest.',
86
+ item_closed: 'This sitting has closed \u2014 refreshing.',
87
+ vote_window_expired: 'This sitting\u2019s clock has run out \u2014 the sweep is closing it.',
88
+ objection_needs_reason: 'An objection must say why \u2014 write the reason.',
89
+ objection_needs_section: 'An objection must point at exactly one section of the idea.',
90
+ yes_carries_nothing: 'A yes carries no reason or section \u2014 those belong to objections.',
91
+ bad_direction: 'A vote is yes or no \u2014 nothing else.',
92
+ item_not_found: 'That item no longer exists \u2014 refreshing.',
93
+ bad_item_id: 'That item id isn\u2019t valid.',
83
94
  };
84
95
  const friendlyError = (code) => ERROR_COPY[code] || null;
85
96
 
@@ -470,6 +481,64 @@
470
481
  </div>`;
471
482
  }
472
483
 
484
+ // ---- the charter library (R19) ---------------------------------------------
485
+
486
+ // The REFERENCE half of the Charter Builder — the seven structures, the three
487
+ // trajectories, the ladder — and deliberately not the configuration machinery
488
+ // (the scale argument in ADR 0175: formality is adopted only where informal
489
+ // coordination breaks). `selectable` means EXPRESSIBLE TODAY through the real
490
+ // config — a rank-predicate membership plus first_ratifier|consent — so nobody
491
+ // is misled about what a settings change can and cannot reach. Source: the
492
+ // Six-Variables synthesis (Verro Labs, Aug 2026), Parts III–IV, via the spec.
493
+ const CHARTERS = [
494
+ { key: 'monarchy', name: 'Monarchy / BDFL', selectable: true,
495
+ how: "membership rank:archon · first ratifier · no clock — the shipped default",
496
+ blurb: 'One trusted executive decides. Fast, legible, and honest at founding scale; the shape every project actually starts in.' },
497
+ { key: 'timocratic-republic', name: 'Timocratic Republic', selectable: true,
498
+ how: 'membership by a trusted rank (e.g. rank:metic) · consent · a timed sitting',
499
+ blurb: 'Standing accrues to those actively building: the franchise follows earned rank, not headcount.' },
500
+ { key: 'direct-democracy', name: 'Direct Democracy', selectable: true,
501
+ how: 'membership rank:xenos (everyone) · consent · a timed sitting',
502
+ blurb: 'Every builder sits on the board. Consent keeps it unblockable: silence passes, one reasoned objection returns.' },
503
+ { key: 'presidential', name: 'Presidential', selectable: false,
504
+ blurb: 'An ELECTED executive with a fixed term. Needs election machinery this system does not have yet — documented for later.' },
505
+ { key: 'cooperative', name: 'Cooperative', selectable: false,
506
+ blurb: 'Formal one-member-one-vote with membership shares and exit rules. Needs the membership-share machinery — documented for later.' },
507
+ { key: 'federation', name: 'Federation', selectable: false,
508
+ blurb: 'Multiple chambers or sub-projects, each self-governing, coordinating through a compact. The >150 shape — documented for later.' },
509
+ { key: 'foundation', name: 'Foundation', selectable: true,
510
+ how: 'a CUSTOM rank as the trustee body (the Ranks tab creates it) · consent · a timed sitting',
511
+ blurb: 'A written charter carried by a small appointed council. Expressible today by governing through a custom rank.' },
512
+ ];
513
+ const TRAJECTORIES = [
514
+ { name: 'Locked', blurb: 'The constitution stays as founded until deliberately amended — no automatic movement.' },
515
+ { name: 'Laddered', blurb: 'Steps up the developmental ladder as the team crosses each headcount threshold.' },
516
+ { name: 'Scheduled', blurb: 'Pre-commits the dates or milestones at which each step happens, decided in advance.' },
517
+ ];
518
+ const LADDER = 'solo/pair → circle (3–8) → ~9 name an executive → ~25 → ~50 written charter + council → ~150 adjudication separates → beyond: federate';
519
+
520
+ function charterCard(c, recommendedKey) {
521
+ const pill = c.selectable
522
+ ? '<span class="gov-pill gov-pill--seeded">selectable today</span>'
523
+ : '<span class="gov-pill gov-pill--locked">documented for later</span>';
524
+ const rec = c.key === recommendedKey ? ' <span class="gov-pill gov-pill--custom">recommended at your headcount</span>' : '';
525
+ const how = c.how ? `<div class="gov-perm__guards">as configuration: ${escapeHtml(c.how)}</div>` : '';
526
+ return `<div class="gov-assign-row"><span class="gov-assign-row__who"><strong>${escapeHtml(c.name)}</strong> — ${escapeHtml(c.blurb)}${how}</span>${pill}${rec}</div>`;
527
+ }
528
+
529
+ function charterLibraryHtml(data) {
530
+ const rec = data.recommended || {};
531
+ return `
532
+ <h2 class="gov-h">Charter library</h2>
533
+ <p class="gov-grid-note">The seven government structures, for getting a team up to speed before deciding when to flip a switch. <strong>Selectable today</strong> means expressible through the real configuration — a rank-based membership plus a pass rule — with no new code. This project has <strong>${escapeHtml(String(data.headcount ?? '?'))}</strong> active builder${data.headcount === 1 ? '' : 's'}: ${escapeHtml(rec.why || '')} <em>(${escapeHtml(rec.rung || '')})</em>.</p>
534
+ <div class="gov-section">${CHARTERS.map((c) => charterCard(c, rec.key)).join('')}</div>
535
+ <h2 class="gov-h">Trajectories</h2>
536
+ <p class="gov-grid-note">How a project moves between structures over time — summarized from the synthesis:</p>
537
+ <div class="gov-section">${TRAJECTORIES.map((t) => `<div class="gov-assign-row"><span class="gov-assign-row__who"><strong>${escapeHtml(t.name)}</strong> — ${escapeHtml(t.blurb)}</span></div>`).join('')}</div>
538
+ <p class="gov-grid-note muted">The developmental ladder: ${escapeHtml(LADDER)}. Formality is adopted only where informal coordination breaks.</p>
539
+ `;
540
+ }
541
+
473
542
  async function loadConstitution() {
474
543
  const box = document.getElementById('gov-constitution');
475
544
  let data;
@@ -494,6 +563,7 @@
494
563
  ${history.length
495
564
  ? `<div class="gov-section">${history.map(historyRow).join('')}</div>`
496
565
  : `<p class="gov-empty">Never amended — this project still runs its founding constitution.</p>`}
566
+ ${charterLibraryHtml(data)}
497
567
  `;
498
568
  }
499
569
 
@@ -551,6 +621,76 @@
551
621
  return `<div class="gov-section">${rows}</div>`;
552
622
  }
553
623
 
624
+ // ---- the vote form (R17) -----------------------------------------------------
625
+
626
+ // The five section labels, mirrored from the base template declaration
627
+ // (modules/ideas/templates.js — the words the author saw when writing; a test
628
+ // holds the two equal, since browser code cannot import a server module).
629
+ const SECTION_LABELS = {
630
+ purpose: 'Purpose of Idea',
631
+ big_picture: 'What does the Big Picture look like',
632
+ why_had: 'Why did you have the Idea',
633
+ why_matters: 'Why does the Idea Matter',
634
+ fit: 'How does the Idea Fit into the big picture of the project',
635
+ };
636
+
637
+ // The idea's five sections, IN FRONT of the voter — the entire reason voting
638
+ // happens in the hall rather than on a reaction. Rendered in template order.
639
+ function ideaSectionsHtml(item) {
640
+ const fv = item.subject && item.subject.field_values;
641
+ if (!fv) return '';
642
+ const rows = Object.keys(SECTION_LABELS).map((key) => `
643
+ <div class="gov-perm">
644
+ <div class="gov-perm__main">
645
+ <code class="gov-perm__key">${escapeHtml(SECTION_LABELS[key])}</code>
646
+ <div class="gov-perm__guards">${escapeHtml(String(fv[key] ?? ''))}</div>
647
+ </div>
648
+ </div>`).join('');
649
+ const score = item.subject.completeness_score;
650
+ return `<details class="gov-section"><summary class="gov-section__head">The idea, in full${score != null ? ` \u00b7 completeness ${escapeHtml(String(score))}/100` : ''}</summary>${rows}</details>`;
651
+ }
652
+
653
+ function voteFormHtml(item, viewerId) {
654
+ if (item.closed_at) return '';
655
+ const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
656
+ if (mine) return '';
657
+ if (item.subject_type === 'full_idea' && item.subject && String(item.subject.author_id) === String(viewerId)) {
658
+ return `<p class="gov-grid-note">This is your idea \u2014 its author does not vote on it. The board decides; a return comes back to you with its reason.</p>`;
659
+ }
660
+ const sections = Object.keys(SECTION_LABELS).map((key) => `
661
+ <label class="gov-perm" style="cursor:pointer"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
662
+ return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
663
+ <button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
664
+ <details class="gov-vote__no">
665
+ <summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
666
+ <p class="gov-grid-note">An objection returns the idea to its author with your reason and the section it faults \u2014 the feedback they revise against.</p>
667
+ <textarea class="gov-input" data-vote-reason placeholder="Why \u2014 the reason the author will act on" maxlength="5000" rows="3"></textarea>
668
+ <div class="gov-vote__sections">${sections}</div>
669
+ <button type="button" class="gov-btn" data-vote="no">Cast objection</button>
670
+ </details>
671
+ </div>`;
672
+ }
673
+
674
+ async function castVoteFromRoom(itemId, direction, container) {
675
+ const body = { direction };
676
+ if (direction === 'no') {
677
+ const reason = (container.querySelector('[data-vote-reason]') || {}).value || '';
678
+ const picked = container.querySelector(`input[name="vote-section-${itemId}"]:checked`);
679
+ body.reason_md = reason;
680
+ if (picked) body.section_key = picked.value;
681
+ }
682
+ try {
683
+ const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
684
+ toast(out.item_closed
685
+ ? `Vote cast \u2014 the sitting closed: ${out.outcome}.`
686
+ : 'Vote cast.');
687
+ } catch (err) {
688
+ if (err && err.kind === '401') return;
689
+ toast(`Could not vote: ${err.message || err}`);
690
+ }
691
+ loadBoard(); // re-render either way — the room shows what stands
692
+ }
693
+
554
694
  function itemCard(item, viewerId) {
555
695
  const yes = item.votes.filter((v) => v.direction === 'yes').length;
556
696
  const no = item.votes.filter((v) => v.direction === 'no').length;
@@ -569,7 +709,9 @@
569
709
  ${amendmentDetail}
570
710
  <p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
571
711
  ${myLine}
712
+ ${item.closed_at ? '' : ideaSectionsHtml(item)}
572
713
  ${ballotHtml(item, viewerId)}
714
+ ${voteFormHtml(item, viewerId)}
573
715
  </section>`;
574
716
  }
575
717
 
@@ -590,6 +732,16 @@
590
732
  (open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
591
733
  + `<h2 class="gov-h">Closed sittings</h2>`
592
734
  + (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
735
+ // R17: the cast buttons. One delegated listener; the SERVER refuses every
736
+ // illegitimate cast regardless (membership, anti-self, closed, expired) —
737
+ // the form is a courtesy, the route is the wall.
738
+ box.querySelectorAll('.gov-vote [data-vote]').forEach((btn) => {
739
+ btn.addEventListener('click', () => {
740
+ const wrap = btn.closest('.gov-vote');
741
+ btn.disabled = true;
742
+ castVoteFromRoom(wrap.dataset.item, btn.dataset.vote, wrap);
743
+ });
744
+ });
593
745
  }
594
746
 
595
747
  // ---- init ----------------------------------------------------------------
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.234",
3
+ "version": "1.19.236",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.234",
9
+ "version": "1.19.236",
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.19.234",
3
+ "version": "1.19.236",
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",
package/src/module-api.js CHANGED
@@ -895,8 +895,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
895
895
  // landed since 1.19.232 with no explicit bump. run 32160823445. (task 1002620)
896
896
  // 1.19.234 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
897
897
  // landed since 1.19.233 with no explicit bump. run 32161368847. (task 1002620)
898
+ // 1.19.235 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
899
+ // landed since 1.19.234 with no explicit bump. run 32161981393. (task 1002620)
900
+ // 1.19.236 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
901
+ // landed since 1.19.235 with no explicit bump. run 32162811851. (task 1002620)
898
902
  // ---------------------------------------------------------------------------
899
- const CORE_VERSION = '1.19.234'; // CI auto-patch carrier (ADR 0161); see changelog above
903
+ const CORE_VERSION = '1.19.236'; // CI auto-patch carrier (ADR 0161); see changelog above
900
904
 
901
905
  // A namespaced logger so a module's log lines are attributable + consistent.
902
906
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -56,7 +56,7 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
56
56
  },
57
57
  getIdeaSubjects: async (ids) => {
58
58
  assert.deepEqual(ids.map(String).sort(), ['6', '7']);
59
- return [{ id: 7n, title: 'A big idea', captured_by: 9n }]; // idea 6 is GONE — deleted subject
59
+ return [{ id: 7n, title: 'A big idea', captured_by: 9n, field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82 }]; // idea 6 is GONE — deleted subject
60
60
  },
61
61
  getAmendmentSubjects: async (ids) => {
62
62
  assert.deepEqual(ids.map(String), ['5']);
@@ -68,7 +68,9 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
68
68
  assert.equal(view.closed.length, 1);
69
69
 
70
70
  const idea = view.open[0];
71
- assert.deepEqual(idea.subject, { title: 'A big idea', author_id: '9' }, 'an idea shows its title + author (R17 greys anti-self from this)');
71
+ assert.deepEqual(idea.subject,
72
+ { title: 'A big idea', author_id: '9', field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82 },
73
+ 'an idea shows its title, author, AND its five sections — the voter decides with the idea in front of them (R17)');
72
74
  assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
73
75
  assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
74
76
  assert.equal(idea.votes.length, 1);
@@ -0,0 +1,70 @@
1
+ // tests/government_board_vote_ui.mjs — the vote UI (BV1.R17 of goal 1000069,
2
+ // criteria C2/C5): cast, reason box, five-section picker — with the full idea
3
+ // in front of the voter, which is the entire reason voting happens in the hall
4
+ // rather than on a Discord reaction.
5
+ //
6
+ // Browser code cannot be imported into node tests wholesale (it is an IIFE over
7
+ // the DOM), so these are SOURCE PINS over the exact seams that must not drift —
8
+ // the same technique the palette and hall audits use — plus one real cross-
9
+ // module equality: the section labels against the template declaration.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
20
+ const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
21
+ const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
22
+
23
+ test('the five section labels ARE the template declaration — key and label, in order', () => {
24
+ const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS'), CLIENT.indexOf('function ideaSectionsHtml'));
25
+ const declared = [...block.matchAll(/(\w+): '([^']+)'/g)].map((m) => [m[1], m[2]]);
26
+ const template = BASE_TEMPLATE.fields.map((f) => [f.key, f.label]);
27
+ assert.deepEqual(declared, template,
28
+ 'the voter reads the same five section names the author wrote under — held equal here because browser code cannot import the server module');
29
+ });
30
+
31
+ test('the idea is in front of the voter: sections render from the subject, in template order', () => {
32
+ assert.match(CLIENT, /item\.subject\.field_values|subject && item\.subject\.field_values/,
33
+ 'the sections come from the board view payload, never a second fetch');
34
+ assert.match(CLIENT, /Object\.keys\(SECTION_LABELS\)\.map/,
35
+ 'rendered in the declared order — jsonb key order is never trusted');
36
+ const card = CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
37
+ assert.match(card, /ideaSectionsHtml\(item\)/, 'the sections sit ON the item card, above the ballot');
38
+ });
39
+
40
+ test('an objection needs the reason AND exactly one section; a yes sends neither', () => {
41
+ const cast = CLIENT.slice(CLIENT.indexOf('async function castVoteFromRoom'), CLIENT.indexOf('function itemCard'));
42
+ assert.match(cast, /if \(direction === 'no'\)/, 'reason + section are attached only to an objection');
43
+ assert.match(cast, /body\.reason_md = reason/, 'the reason box feeds reason_md');
44
+ assert.match(cast, /body\.section_key = picked\.value/, 'the picker feeds exactly one section_key');
45
+ assert.match(cast, /\/government\/board\/items\/.*\/votes/, 'casts into R08\'s route — the server is the wall');
46
+ assert.match(cast, /loadBoard\(\)/, 'the room re-renders either way — it shows what stands');
47
+ assert.match(cast, /item_closed/, 'a cast that CLOSED the sitting says so (first_ratifier / full turnout)');
48
+ });
49
+
50
+ test('the author of a full_idea sees the anti-self note instead of a form — amendments do not', () => {
51
+ const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
52
+ assert.match(form, /subject_type === 'full_idea'[^]*author_id.*viewerId/,
53
+ 'the grey-out is SUBJECT-TYPED, mirroring castVote\'s carve-out — an amendment proposer may vote (§7)');
54
+ assert.match(form, /does not vote on it/, 'the note says why, not just no');
55
+ assert.match(form, /if \(mine\) return ''/, 'one vote per member — a cast voter gets no second form');
56
+ assert.match(form, /if \(item\.closed_at\) return ''/, 'closed sittings take no votes');
57
+ });
58
+
59
+ test('every refusal castVote can return has friendly copy', () => {
60
+ // The route maps board.js's typed refusals to res.fail codes; the client's
61
+ // ERROR_COPY must name each one so a refused vote explains itself.
62
+ const source = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
63
+ const castBlock = source.slice(source.indexOf('function validateVote'), source.indexOf('// ── close + tally'));
64
+ const reasons = [...new Set([...castBlock.matchAll(/reason: '([a-z_]+)'/g)].map((m) => m[1]))];
65
+ assert.ok(reasons.length >= 6, `sanity: found castVote's refusal set (${reasons})`);
66
+ const copyBlock = CLIENT.slice(CLIENT.indexOf('const ERROR_COPY'), CLIENT.indexOf('const friendlyError'));
67
+ for (const r of reasons) {
68
+ assert.ok(copyBlock.includes(`${r}:`), `ERROR_COPY must explain '${r}'`);
69
+ }
70
+ });
@@ -0,0 +1,77 @@
1
+ // tests/government_charter_library.mjs — the charter library (BV1.R19 of goal
2
+ // 1000069, criterion C5): seven constitutions, three trajectories, the ladder —
3
+ // and ONE live datum, the recommendation at this project's actual headcount.
4
+ //
5
+ // The recommendation is SERVER-SIDE and pure precisely so R26's proof can hold
6
+ // it against the real number; the library prose is client reference content.
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { createRequire } from 'node:module';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const GOV = path.join(ROOT, 'modules', 'government');
17
+ const board = require(path.join(GOV, 'board.js'));
18
+ const db = require(path.join(GOV, 'db.js'));
19
+
20
+ const { recommendedCharter } = board;
21
+
22
+ test('the ladder bands track exactly the thresholds the synthesis names', () => {
23
+ assert.equal(recommendedCharter(1).key, 'monarchy');
24
+ assert.equal(recommendedCharter(2).key, 'monarchy', 'this project today: two builders → the founding monarchy');
25
+ assert.equal(recommendedCharter(3).key, 'direct-democracy', 'the circle rung starts at 3');
26
+ assert.equal(recommendedCharter(8).key, 'direct-democracy');
27
+ assert.equal(recommendedCharter(9).key, 'presidential', '~9: name an executive');
28
+ assert.equal(recommendedCharter(49).key, 'presidential');
29
+ assert.equal(recommendedCharter(50).key, 'foundation', '~50: written charter + council');
30
+ assert.equal(recommendedCharter(149).key, 'foundation');
31
+ assert.equal(recommendedCharter(150).key, 'federation', '~150: adjudication separates — federate');
32
+ assert.equal(recommendedCharter(0).key, 'monarchy', 'a zero/unknown headcount fails to the founding shape');
33
+ assert.equal(recommendedCharter('nonsense').key, 'monarchy');
34
+ });
35
+
36
+ test('constitutionView carries the live headcount and its recommendation', async () => {
37
+ const originals = { listAmendmentHistory: db.listAmendmentHistory, countActiveBuilders: db.countActiveBuilders };
38
+ db.listAmendmentHistory = async () => [];
39
+ db.countActiveBuilders = async () => 7;
40
+ try {
41
+ const view = await board.constitutionView();
42
+ assert.equal(view.headcount, 7);
43
+ assert.equal(view.recommended.key, 'direct-democracy');
44
+ assert.ok(view.recommended.rung.includes('circle'));
45
+ } finally { Object.assign(db, originals); }
46
+ });
47
+
48
+ test('the headcount counts the same population membership does (SQL pin)', () => {
49
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
50
+ const block = src.slice(src.indexOf('async function countActiveBuilders'), src.indexOf("// The constitution's change HISTORY"));
51
+ assert.match(block, /status = 'active'/);
52
+ assert.match(block, /system_role IS NULL/, 'a machine principal is not a team member');
53
+ });
54
+
55
+ test('the library presents all seven structures with honest selectable marks (client source pins)', () => {
56
+ const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
57
+ const lib = client.slice(client.indexOf('const CHARTERS'), client.indexOf('function charterCard'));
58
+ for (const name of ['Monarchy / BDFL', 'Timocratic Republic', 'Direct Democracy', 'Presidential', 'Cooperative', 'Federation', 'Foundation']) {
59
+ assert.ok(lib.includes(`name: '${name}'`), `the library must present ${name}`);
60
+ }
61
+ // Selectable ⇔ expressible through the REAL config (a rank predicate + a
62
+ // shipped pass rule). Every selectable entry must SAY HOW; every
63
+ // documented-for-later entry must not pretend.
64
+ const selectableTrue = (lib.match(/selectable: true/g) || []).length;
65
+ const selectableFalse = (lib.match(/selectable: false/g) || []).length;
66
+ assert.equal(selectableTrue + selectableFalse, 7, 'every structure is marked, none left ambiguous');
67
+ assert.equal(selectableTrue, 4, 'monarchy, timocratic republic, direct democracy, foundation — the config-expressible four');
68
+ const trueBlocks = lib.split('selectable: true').slice(1);
69
+ for (const b of trueBlocks) {
70
+ assert.ok(b.slice(0, 200).includes('how:'), 'a selectable structure names its configuration');
71
+ }
72
+ assert.match(client, /recommended at your headcount/, 'the one live datum is rendered');
73
+ assert.match(client, /c\.key === recommendedKey/, "the pill is driven by the SERVER's recommendation, never re-derived client-side");
74
+ for (const t of ['Locked', 'Laddered', 'Scheduled']) {
75
+ assert.ok(client.includes(`name: '${t}'`), `trajectory ${t} present`);
76
+ }
77
+ });
@@ -33,7 +33,7 @@ async function withDb(stubs, fn) {
33
33
  }
34
34
 
35
35
  test('the live board block is RESOLVED per request — this vanilla checkout reads monarchy', async () => {
36
- const view = await withDb({ listAmendmentHistory: async () => [] }, () => board.constitutionView());
36
+ const view = await withDb({ listAmendmentHistory: async () => [], countActiveBuilders: async () => 2 }, () => board.constitutionView());
37
37
  assert.deepEqual(view.board, BOARD_DEFAULTS,
38
38
  'no instance pack here, so the resolved value IS the neutral monarchy — never a hardcoded copy');
39
39
  assert.deepEqual(view.history, []);
@@ -42,6 +42,7 @@ test('the live board block is RESOLVED per request — this vanilla checkout rea
42
42
  test('the history projects passed AND returned, with proposer identity and the decided-under snapshot', async () => {
43
43
  const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
44
44
  const view = await withDb({
45
+ countActiveBuilders: async () => 2,
45
46
  listAmendmentHistory: async (limit) => {
46
47
  assert.equal(limit, 50);
47
48
  return [