@bongos/core 1.19.363 → 1.19.364

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.363",
6
- "core_contract": "1.19.363",
7
- "source_commit": "dfe3198cbb7ad4104276759b209d6064e4075753",
5
+ "core_version": "1.19.364",
6
+ "core_contract": "1.19.364",
7
+ "source_commit": "c0a00b10a71f2436b4c7c3ccf91f71738cd319dc",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-25T19:24:12.550Z",
9
+ "built_at": "2026-08-25T19:33:41.499Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 330,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1527,
14
+ "functional_verbatim": 1528,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1879,
20
- "tree_sha256": "11a13769c884c5b71eefc6c2d9d62e17d1096f782583e2b0cdd46ae4a92cf52d",
19
+ "file_count": 1880,
20
+ "tree_sha256": "bc6cc5b48ee9fd713a92fd676a2e776eddc5d7a421b2ac69fba5c2a3d1122825",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2552,7 +2552,7 @@
2552
2552
  {
2553
2553
  "path": "modules/builder-settings/builder-needs.js",
2554
2554
  "mode": "0000644",
2555
- "sha256": "276a92a2000f2bc0e9e157791947f2ee7e6bfe934a15a996d42dba13984c26d7"
2555
+ "sha256": "0668dd0b60184bb230e60f13ee38dffb3a7831264e5155c3bafab3a2dce0f7f5"
2556
2556
  },
2557
2557
  {
2558
2558
  "path": "modules/builder-settings/db.js",
@@ -2937,7 +2937,7 @@
2937
2937
  {
2938
2938
  "path": "modules/discord/board-broadcast.js",
2939
2939
  "mode": "0000644",
2940
- "sha256": "111b577698792565f55e66f031c6567ac548b4fc5194b51c262843b2bec66198"
2940
+ "sha256": "36906948331be98d0e14b7d0a02d0c86533b2d6247a3506e88c5c52827615411"
2941
2941
  },
2942
2942
  {
2943
2943
  "path": "modules/discord/bug-attachments.js",
@@ -3027,7 +3027,7 @@
3027
3027
  {
3028
3028
  "path": "modules/discord/webhook.js",
3029
3029
  "mode": "0000644",
3030
- "sha256": "59d2452516f4568c2a99c599275224ff0d2ff86b763066469a567944efbf5f69"
3030
+ "sha256": "f8dc8f411b243d19d28b0b2f25fdc08925554e2d1b329ed2571aa7461b8cb64a"
3031
3031
  },
3032
3032
  {
3033
3033
  "path": "modules/economy/CLAUDE.md",
@@ -3112,7 +3112,7 @@
3112
3112
  {
3113
3113
  "path": "modules/government/board.js",
3114
3114
  "mode": "0000644",
3115
- "sha256": "e6e01e38328eb6d234c76d7f5aa9e96589fd2c9ef89c105216495303ac1e3fdd"
3115
+ "sha256": "97f51120176aabc2bf414d8b5fff4a7d016b7522ac505c41b3d1fb25b258bfd1"
3116
3116
  },
3117
3117
  {
3118
3118
  "path": "modules/government/catalog.js",
@@ -3127,7 +3127,7 @@
3127
3127
  {
3128
3128
  "path": "modules/government/db.js",
3129
3129
  "mode": "0000644",
3130
- "sha256": "e48b8e01738969ca589496ee0bea94b5a31995e853a1347f63ca83895d4d28fd"
3130
+ "sha256": "99be259855d63808adca140222eaf8563cff054b06ecc1acdd49594fcfc129fe"
3131
3131
  },
3132
3132
  {
3133
3133
  "path": "modules/government/membership-predicate.js",
@@ -3242,7 +3242,7 @@
3242
3242
  {
3243
3243
  "path": "modules/government/routes/government.js",
3244
3244
  "mode": "0000644",
3245
- "sha256": "8b29ea6478f4854dd5c74ea3538079db818707a575318634ce4f7504d2768289"
3245
+ "sha256": "a0406671733e7f1a80212652c8d03d95899cdac90093edffe8186b1e11d774a6"
3246
3246
  },
3247
3247
  {
3248
3248
  "path": "modules/government/routes/rank-sync.js",
@@ -4852,12 +4852,12 @@
4852
4852
  {
4853
4853
  "path": "package-lock.json",
4854
4854
  "mode": "0000644",
4855
- "sha256": "44644726a2b97729a15e7220d9bc5aa7b7a394580ae95da58b452e8824688ff6"
4855
+ "sha256": "069dd3a2cb3320bdb8ba90936408e7ffa69100e7221f2131f6ff58eb300d0731"
4856
4856
  },
4857
4857
  {
4858
4858
  "path": "package.json",
4859
4859
  "mode": "0000644",
4860
- "sha256": "2dc2136427cf6df206810b410527d3cc09cb3ac72c663f49d0c0ad3bb8a3951c"
4860
+ "sha256": "6a845cb51d1752ff573c18b2b9a9b078277beebb1d5b352ae04d6730ec4e6bdb"
4861
4861
  },
4862
4862
  {
4863
4863
  "path": "public-docs/index.html",
@@ -6652,7 +6652,7 @@
6652
6652
  {
6653
6653
  "path": "src/bongos/routes/me.js",
6654
6654
  "mode": "0000644",
6655
- "sha256": "0006bf8ec2430532a72c194bcc7517b4008d00b8fa4a73e9a77bc2c6a10c6d26"
6655
+ "sha256": "34e50788c8162430b46e9f204c36396b456d0524bde3063434bb848d7977cf07"
6656
6656
  },
6657
6657
  {
6658
6658
  "path": "src/bongos/routes/modules.js",
@@ -6722,7 +6722,7 @@
6722
6722
  {
6723
6723
  "path": "src/module-api.js",
6724
6724
  "mode": "0000644",
6725
- "sha256": "df48c1828cda7dc37f655dcb890871fd2fd5867f223823eff07a8ae74dd1febe"
6725
+ "sha256": "b49355a43b920214aa7295016e9776f61a744904be33b2c4c9e88cd987071855"
6726
6726
  },
6727
6727
  {
6728
6728
  "path": "src/module-loader/catalog.js",
@@ -7467,7 +7467,7 @@
7467
7467
  {
7468
7468
  "path": "tests/discord_board_broadcast.mjs",
7469
7469
  "mode": "0000644",
7470
- "sha256": "db1a02168869e633fccc62137f4db3e0f4efbf4a785db7cc0b49a30a8d4f418a"
7470
+ "sha256": "f3ac413d7653d3027516eae052523b98206cb54452cb3410602e1bb85c4d4c19"
7471
7471
  },
7472
7472
  {
7473
7473
  "path": "tests/discord_bot.mjs",
@@ -7789,6 +7789,11 @@
7789
7789
  "mode": "0000644",
7790
7790
  "sha256": "7da08f5ad67ca1d42d0ef2afb641d1a5f1d5e0c6e0267e8b5f3ad0d275603a36"
7791
7791
  },
7792
+ {
7793
+ "path": "tests/government_board_summons.mjs",
7794
+ "mode": "0000644",
7795
+ "sha256": "349bf135c0c38fb23d40142e3aaa2a5aada9484a4cead9c5b312450a9f818c07"
7796
+ },
7792
7797
  {
7793
7798
  "path": "tests/government_board_view.mjs",
7794
7799
  "mode": "0000644",
@@ -148,7 +148,50 @@ function taskRecommendationsNeed(ctx) {
148
148
  };
149
149
  }
150
150
 
151
- const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed];
151
+ // Open BOARD SITTINGS waiting on this builder's vote (task 1003271). Sibling of
152
+ // the two collab needs above, and here for the sharpest version of the same
153
+ // reason: nine items sat before the board for two days with zero votes, not
154
+ // because the board was deadlocked — three of four archons could each have
155
+ // carried any of them with one click — but because nobody was ever told. The
156
+ // only summons that existed was the Discord announcer, which is best-effort by
157
+ // construction: an unconfigured webhook, an unlinked member or a failed POST all
158
+ // end in a swallowed log line. A vote nobody surfaces is merely pending.
159
+ //
160
+ // ctx.pendingBoardVotes — { count, first_item_id } from the `government` port,
161
+ // already filtered to what the vote route would ACCEPT
162
+ // (member under the item's own snapshotted constitution,
163
+ // not the author, has not yet cast, not past deadline).
164
+ // null/absent on an instance with government off, which
165
+ // reads as "nothing waiting".
166
+ //
167
+ // 'action_needed', unlike the two info-only collab needs: an open sitting is the
168
+ // system genuinely blocked on this person's input — until a member votes, the
169
+ // idea's author waits and nothing decides. The link goes straight to the oldest
170
+ // waiting sitting, because the one that has waited longest is the one to answer.
171
+ function boardVotesNeed(ctx) {
172
+ const pending = ctx.pendingBoardVotes;
173
+ const n = Number(pending && pending.count) || 0;
174
+ if (n <= 0) return null;
175
+ const firstId = pending && pending.first_item_id;
176
+ // Deep-link the named sitting when we know it (?item=<id> — the Board Room
177
+ // panel scrolls to and highlights it; the hash stays the tab), else the room.
178
+ const href = firstId
179
+ ? `/government?item=${encodeURIComponent(firstId)}#board-room`
180
+ : '/government#board-room';
181
+ const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
182
+ return {
183
+ id: 'board_votes',
184
+ title: n === 1 ? 'An idea is waiting on your vote' : 'Ideas are waiting on your vote',
185
+ what: 'You sit on the board, and a Full Idea that cleared the completeness grade needs a decision.',
186
+ state: 'action_needed',
187
+ severity: 'info',
188
+ message: `${subject} before the board and cannot be decided until a member votes. `
189
+ + 'Voting happens in the hall, where the idea is in front of you and an objection can name its reason.',
190
+ action: { label: n === 1 ? 'Open the sitting' : 'Open the Board Room', href },
191
+ };
192
+ }
193
+
194
+ const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed];
152
195
 
153
196
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
154
197
  //
@@ -226,7 +269,7 @@ module.exports = {
226
269
  computeNeeds,
227
270
  // exported for tests / direct reuse
228
271
  artKeyNeed,
229
- helpRequestsNeed, taskRecommendationsNeed,
272
+ helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed,
230
273
  foundingGraceActive,
231
274
  FOUNDING_GRACE_DAYS,
232
275
  SETTINGS_PATH,
@@ -27,6 +27,14 @@
27
27
  // BEST-EFFORT BY DESIGN, like every broadcast in this module: it never throws,
28
28
  // so a Discord hiccup can never fail a window open or a close — the room in the
29
29
  // hall is the durable surface, this is the nudge.
30
+ //
31
+ // AND IT IS NO LONGER THE ONLY SUMMONS (task 1003271). Being best-effort meant
32
+ // an unconfigured webhook or an unlinked member ended in a swallowed log line,
33
+ // and nine sittings once sat two days with zero votes because nobody was told.
34
+ // The durable summons is now the builder-needs roster on /me (government's
35
+ // pendingBoardVotesFor → the `board_votes` need), which every session and hall
36
+ // visit already reads. This stays what §8 says it is: a notification, never a
37
+ // ballot.
30
38
 
31
39
  'use strict';
32
40
 
@@ -59,6 +67,24 @@ async function postBoardAnnouncement(payload, deps = {}) {
59
67
  if (!resolveMembers) { result.skipped = 'no_government_port'; return result; }
60
68
  const members = await resolveMembers(payload.constitution || {});
61
69
  if (!members || !members.length) { result.skipped = 'empty_board'; return result; }
70
+ // task 1003271: an unconfigured webhook is an OPERATOR action, not weather.
71
+ // Reported distinctly (and named in the log) so "this instance never wired
72
+ // the board channel" stops looking identical to "Discord answered 500" —
73
+ // that ambiguity is why nine sittings went unannounced without anyone
74
+ // knowing which of the two to fix. The hall's builder-needs summons is the
75
+ // durable one either way; this is the nudge.
76
+ //
77
+ // Only asked when the REAL webhook poster is the one about to run: an
78
+ // injected `post` (the tests, and any future non-webhook transport) resolves
79
+ // no URL, so a missing one tells us nothing about whether it can deliver.
80
+ const configured = deps.hasWebhook || webhook.hasWebhook;
81
+ if (!deps.post && typeof configured === 'function' && !configured(CHANNEL)) {
82
+ result.skipped = 'channel_not_configured';
83
+ log(`[discord] board announcement skipped for item ${payload.item_id}: no webhook for '${CHANNEL}'. `
84
+ + `Set it in ${webhook.WEBHOOKS_PATH} or env DISCORD_${CHANNEL.toUpperCase()}_WEBHOOK. `
85
+ + `The hall summons (builder-needs) is unaffected — members are still told in the hall.`);
86
+ return result;
87
+ }
62
88
 
63
89
  // ONE bulk query for the whole board's links — never a per-member await.
64
90
  const getLinks = deps.getLinks
@@ -111,7 +137,8 @@ async function postBoardAnnouncement(payload, deps = {}) {
111
137
  log(`[discord] board announcement posted (item ${payload.item_id}; ${result.mentioned.length} mentioned, ${result.named.length} named)`);
112
138
  } catch (e) {
113
139
  result.skipped = 'post_failed';
114
- log(`[discord] board announcement skipped (window open unaffected): ${e && e.message}`);
140
+ log(`[discord] board announcement skipped for item ${payload.item_id} (window open unaffected; `
141
+ + `the hall summons still stands): ${e && e.message}`);
115
142
  }
116
143
  return result;
117
144
  }
@@ -133,7 +133,21 @@ async function editWebhook(channel, messageId, text) {
133
133
  return { ok: true, status: res.status };
134
134
  }
135
135
 
136
+ // Is a channel actually wired up? (task 1003271.) Callers whose post is
137
+ // best-effort swallow every failure, which makes "this instance never configured
138
+ // the webhook" indistinguishable from "Discord returned 500" in the logs — and
139
+ // the first is an operator action while the second is weather. This answers the
140
+ // cheap half without attempting a post. False for an unknown channel too: a name
141
+ // nothing can resolve is not configured.
142
+ function hasWebhook(channel) {
143
+ try {
144
+ return !!(CHANNELS.has(channel) && loadWebhooks()[channel]);
145
+ } catch (_) {
146
+ return false;
147
+ }
148
+ }
149
+
136
150
  module.exports = {
137
- CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl,
151
+ CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl, hasWebhook,
138
152
  postWebhook, postWebhookMentions, editWebhook,
139
153
  };
@@ -719,7 +719,74 @@ async function latestRejectionForIdea(ideaId) {
719
719
  });
720
720
  }
721
721
 
722
+ // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
723
+ //
724
+ // THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
725
+ // votes. The board was not deadlocked — three of the four archons could each
726
+ // have carried any of them with one click — they simply never learned a vote was
727
+ // wanted. The Discord announcer (R21) is the only summons there has ever been,
728
+ // and it is BEST-EFFORT BY CONSTRUCTION: an unconfigured webhook, an unlinked
729
+ // member, or a failed POST all end in a swallowed log line. A nudge that can
730
+ // silently not happen cannot be the only nudge.
731
+ //
732
+ // So the durable summons is the one surface every session and every hall visit
733
+ // already reads: the builder-needs roster on /me (ADR 0073). This function is
734
+ // its counter. Discord stays exactly what §8 says it is — a notification, not a
735
+ // ballot — and voting stays hall-only (ADR 0175 §8, owner decision 2026-08-25).
736
+ //
737
+ // WHY MEMBERSHIP IS RESOLVED HERE AND NOT IN SQL. Each item snapshots the
738
+ // constitution it opened under, so "may this builder vote on THIS item" is a
739
+ // per-item predicate over the LIVE rank rows — the ADR 0016 posture the rest of
740
+ // this module keeps (per request, uncached). Resolved once per DISTINCT
741
+ // predicate rather than once per item, so nine items under one constitution cost
742
+ // one membership resolve, not nine.
743
+ //
744
+ // It counts what the vote route would ACCEPT, so the roster can never promise a
745
+ // ballot the route then refuses:
746
+ // - open, not past its deadline (the SQL's WHERE)
747
+ // - this builder has not already cast (UNIQUE(item_id, voter_id) — one vote each)
748
+ // - this builder is a board member under the item's OWN snapshot
749
+ // - this builder is not the subject's author (the anti-self-vote line, §3)
750
+ // Fail-closed on an unreadable constitution: parseMembershipPredicate yields
751
+ // nobody, so a malformed snapshot summons no one rather than everyone.
752
+ async function pendingVotesFor(builderId) {
753
+ const empty = Object.freeze({ count: 0, first_item_id: null });
754
+ if (builderId === null || builderId === undefined || builderId === '') return empty;
755
+ let rows;
756
+ try {
757
+ rows = await db.openBoardItemsAwaitingVoter(builderId);
758
+ } catch (_) {
759
+ // A summons that cannot be counted is a quiet session, never a failed /me.
760
+ return empty;
761
+ }
762
+ if (!rows || !rows.length) return empty;
763
+
764
+ // One membership answer per distinct predicate. The predicate string IS the
765
+ // cache key: two items under the same membership have the same answer, and the
766
+ // resolve is a DB read we should not repeat nine times on the session hot path.
767
+ const membershipCache = new Map();
768
+ const votable = [];
769
+ for (const row of rows) {
770
+ if (row.already_voted) continue;
771
+ // The author does not vote on their own item (§3). Null author = nobody to
772
+ // disqualify, so the item stays votable.
773
+ if (row.author_id != null && String(row.author_id) === String(builderId)) continue;
774
+ const constitution = row.constitution || {};
775
+ const key = String(constitution.membership || '');
776
+ if (!membershipCache.has(key)) {
777
+ membershipCache.set(key, await isBoardMember(builderId, constitution).catch(() => false));
778
+ }
779
+ if (!membershipCache.get(key)) continue;
780
+ votable.push(row);
781
+ }
782
+ if (!votable.length) return empty;
783
+ // Oldest first (the SQL's ORDER BY), so the deep link lands on the sitting
784
+ // that has been waiting longest rather than an arbitrary one.
785
+ return Object.freeze({ count: votable.length, first_item_id: String(votable[0].id) });
786
+ }
787
+
722
788
  module.exports = {
789
+ pendingVotesFor,
723
790
  FULL_IDEA_PASS_BAR,
724
791
  ideaPassesBar,
725
792
  openWindowForFullIdea,
@@ -618,6 +618,52 @@ async function getExpiredOpenBoardItems(limit = 100) {
618
618
  return rows;
619
619
  }
620
620
 
621
+ // The summons read (task 1003271): every OPEN item this builder could still
622
+ // vote on, with the two facts that decide it — who authored the subject, and
623
+ // whether this builder has already cast. Membership is NOT decided here: it
624
+ // depends on the item's SNAPSHOTTED constitution predicate, which board.js
625
+ // resolves against the live rank rows (the ADR 0016 posture — per request,
626
+ // uncached). This query's job is to make that resolution cheap by handing back
627
+ // everything else in ONE round trip instead of three per item.
628
+ //
629
+ // Expired items are excluded: a sitting past its deadline is the expiry sweep's
630
+ // to close, and nagging someone to vote on it would be asking for a vote the
631
+ // close will not count.
632
+ //
633
+ // The author join mirrors getBoardItemAuthor exactly — full_idea authorship is
634
+ // idea_inbox.captured_by, and anything else is opened_by. Kept as a LEFT JOIN so
635
+ // an item whose idea row is gone still appears with a null author (it has no
636
+ // author to disqualify, so it stays votable) rather than vanishing from the count.
637
+ //
638
+ // INDEXES THIS RELIES ON — all three already exist (government_003), which is why
639
+ // this adds no migration:
640
+ // - government_board_items_open_by_deadline ON (closes_at) WHERE closed_at IS NULL
641
+ // — a PARTIAL index whose predicate is exactly this query's `closed_at IS NULL`,
642
+ // and whose key is the `closes_at` it then filters on.
643
+ // - government_board_votes_one_per_voter UNIQUE (item_id, voter_id) — the votes
644
+ // LEFT JOIN's two columns, in that order, so "did this voter cast on this item"
645
+ // is one index probe rather than a scan of the item's ballot.
646
+ // - idea_inbox's primary key, for the author join.
647
+ // The open set is also intrinsically small (an open item is one awaiting a decision,
648
+ // not history — the closed rows are the unbounded side, per ADR 0175 §4).
649
+ async function openBoardItemsAwaitingVoter(voterId) {
650
+ const { rows } = await pool.query(
651
+ `SELECT i.id, i.subject_type, i.subject_id, i.constitution, i.opened_at, i.closes_at,
652
+ CASE WHEN i.subject_type = 'full_idea' THEN idea.captured_by ELSE i.opened_by END AS author_id,
653
+ (v.id IS NOT NULL) AS already_voted
654
+ FROM government_board_items i
655
+ LEFT JOIN idea_inbox idea
656
+ ON i.subject_type = 'full_idea' AND idea.id = i.subject_id
657
+ LEFT JOIN government_board_votes v
658
+ ON v.item_id = i.id AND v.voter_id = $1
659
+ WHERE i.closed_at IS NULL
660
+ AND (i.closes_at IS NULL OR i.closes_at >= now())
661
+ ORDER BY i.opened_at`,
662
+ [voterId],
663
+ );
664
+ return rows;
665
+ }
666
+
621
667
  // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
622
668
  // the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
623
669
  // and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
@@ -645,6 +691,7 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
645
691
  }
646
692
 
647
693
  module.exports = {
694
+ openBoardItemsAwaitingVoter,
648
695
  getPermissionKeysForBuilder,
649
696
  getRankKeysForBuilder,
650
697
  getSeededRanks,
@@ -93,6 +93,12 @@ const governmentPort = {
93
93
  // bar gate lives INSIDE board.js (a sub-bar idea never reaches the board, and
94
94
  // no caller is trusted to pre-filter). Returns, never throws into capture.
95
95
  openBoardWindowForIdea: board.openWindowForFullIdea,
96
+ // task 1003271: how many open sittings are waiting on THIS builder's vote
97
+ // (member under the item's own snapshot, not the author, has not yet cast).
98
+ // On the port because the summons is rendered by the kernel's builder-needs
99
+ // roster on /me — the one surface every session already reads — and core
100
+ // imports no module (ADR 0093).
101
+ pendingBoardVotesFor: board.pendingVotesFor,
96
102
  // R13: the feedback a board-returned idea carries back to its author — the
97
103
  // resubmit path in modules/ideas resolves this to show what was objected to
98
104
  // and to check whether the flagged section actually changed.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.363",
3
+ "version": "1.19.364",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.363",
9
+ "version": "1.19.364",
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.363",
3
+ "version": "1.19.364",
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",
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
105
105
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
106
106
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
107
107
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
108
- taskRecommendationCount,
108
+ taskRecommendationCount, boardVotesPending,
109
109
  ] = await Promise.all([
110
110
  db.getBuilderById(req.builder.id),
111
111
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -192,6 +192,19 @@ module.exports = function buildMeRouter() {
192
192
  return await lifecycle.openTaskRecommendationsFor(req.builder.id);
193
193
  } catch (_) { return 0; }
194
194
  })(),
195
+ // task 1003271: open board sittings waiting on THIS builder's vote, via
196
+ // the `government` port — core imports no module (ADR 0093). Nine items
197
+ // once sat for two days with nobody told, because the Discord announcer
198
+ // (best-effort by construction) was the only summons; this is the durable
199
+ // one. Same fan-out slot and fail-open posture as the two collab reads
200
+ // above: a board read must never be able to break a profile.
201
+ (async () => {
202
+ try {
203
+ const government = seams.resolveOptional('government');
204
+ if (!government || !government.pendingBoardVotesFor) return null;
205
+ return await government.pendingBoardVotesFor(req.builder.id);
206
+ } catch (_) { return null; }
207
+ })(),
195
208
  ]);
196
209
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
197
210
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -333,6 +346,10 @@ module.exports = function buildMeRouter() {
333
346
  // module (ADR 0093); undefined when lifecycle is off, which the need reads
334
347
  // as "nothing asked".
335
348
  openHelpRequests: helpRequestCount,
349
+ // task 1003271: { count, first_item_id } of open board sittings awaiting
350
+ // this builder's vote; null when government is off, which the need reads
351
+ // as "nothing waiting".
352
+ pendingBoardVotes: boardVotesPending,
336
353
  // task 1003173 (C03, goal 1000075): open task recommendations waiting on
337
354
  // this builder, read through the same `lifecycle` port so core never
338
355
  // imports the module (ADR 0093).
package/src/module-api.js CHANGED
@@ -1156,8 +1156,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1156
1156
  // landed since 1.19.361 with no explicit bump. run 32886106098. (task 1002620)
1157
1157
  // 1.19.363 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1158
1158
  // landed since 1.19.362 with no explicit bump. run 32889324497. (task 1002620)
1159
+ // 1.19.364 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1160
+ // landed since 1.19.363 with no explicit bump. run 32890225073. (task 1002620)
1159
1161
  // ---------------------------------------------------------------------------
1160
- const CORE_VERSION = '1.19.363'; // CI auto-patch carrier (ADR 0161); see changelog above
1162
+ const CORE_VERSION = '1.19.364'; // CI auto-patch carrier (ADR 0161); see changelog above
1161
1163
 
1162
1164
  // A namespaced logger so a module's log lines are attributable + consistent.
1163
1165
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -79,6 +79,45 @@ test('missing port, empty board, and a Discord failure are named skips — NEVER
79
79
  assert.equal((await postBoardAnnouncement(null, { log: () => {} })).skipped, 'no_item');
80
80
  });
81
81
 
82
+ test('an unconfigured board channel is its OWN named skip, not a generic failure (task 1003271)', async () => {
83
+ // WHY THIS DISTINCTION EARNS A TEST. Nine sittings went unannounced and the
84
+ // logs could not say whether the webhook was never configured (an operator
85
+ // action) or Discord had failed (weather). Both used to read 'post_failed'.
86
+ const deps = {
87
+ resolveMembers: async () => [member(1, 'a')],
88
+ getLinks: async () => new Map(),
89
+ hasWebhook: () => false,
90
+ log: () => {},
91
+ };
92
+ const out = await postBoardAnnouncement(payload(), deps);
93
+ assert.deepEqual({ posted: out.posted, skipped: out.skipped },
94
+ { posted: false, skipped: 'channel_not_configured' });
95
+
96
+ // Configured but failing is still the OTHER skip — the two never collapse.
97
+ const failed = await postBoardAnnouncement(payload(), {
98
+ ...deps, hasWebhook: () => true, post: async () => { throw new Error('discord down'); },
99
+ });
100
+ assert.equal(failed.skipped, 'post_failed');
101
+
102
+ // An INJECTED poster resolves no webhook URL, so a missing one must not stop
103
+ // it — otherwise every deps-injected caller would report a false skip.
104
+ let sent = null;
105
+ const injected = await postBoardAnnouncement(payload(), {
106
+ ...deps, hasWebhook: () => false, post: async (_c, text) => { sent = text; return { message_id: '5' }; },
107
+ recordMessage: async () => {},
108
+ });
109
+ assert.equal(injected.posted, true, 'an injected poster is never gated on webhook config');
110
+ assert.ok(sent && sent.includes('before the board'));
111
+ });
112
+
113
+ test('hasWebhook answers false for an unknown channel and for a configured-but-absent one', () => {
114
+ assert.equal(webhook.hasWebhook('definitely_not_a_channel'), false);
115
+ assert.ok(webhook.CHANNELS.has('board_room'), 'the board channel is a known key');
116
+ // Its truth here depends on the environment, so pin the TYPE, not the value —
117
+ // a boolean is the contract the caller branches on.
118
+ assert.equal(typeof webhook.hasWebhook('board_room'), 'boolean');
119
+ });
120
+
82
121
  test('the emitter fires on FRESH opens only, and the listener is registered (source pins)', () => {
83
122
  const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
84
123
  const open = board.slice(board.indexOf('async function openWindowForFullIdea'), board.indexOf('async function emitWindowOpened'));
@@ -0,0 +1,222 @@
1
+ // tests/government_board_summons.mjs — the summons: what is waiting on THIS
2
+ // member's vote (task 1003271).
3
+ //
4
+ // THE BUG THIS PINS. Nine sittings sat open for two days with zero votes. The
5
+ // board was never deadlocked — three of four archons could each have carried any
6
+ // item with one click — they were simply never told, because the Discord
7
+ // announcer was the only summons and it is best-effort by construction (an
8
+ // unconfigured webhook, an unlinked member and a failed POST all end in a
9
+ // swallowed log line). The durable summons is now the builder-needs roster.
10
+ //
11
+ // The rules pinned here:
12
+ // 1. THE COUNT MATCHES WHAT THE VOTE ROUTE WOULD ACCEPT — a member, not the
13
+ // author, not already cast. Anything the route would refuse must not be
14
+ // counted, or the roster promises a ballot the route then denies.
15
+ // 2. THE AUTHOR IS EXCLUDED, and that is the whole live symptom: the owner
16
+ // authored all nine, so their own count is zero while a peer's is nine.
17
+ // 3. MEMBERSHIP IS PER-ITEM, off the item's OWN snapshotted constitution —
18
+ // never the live config — and resolved ONCE PER DISTINCT PREDICATE.
19
+ // 4. FAIL-CLOSED AND FAIL-OPEN IN THE RIGHT DIRECTIONS: an unreadable
20
+ // predicate summons nobody; a DB error yields a zero count, never a throw
21
+ // (this runs on the /me session hot path and must not break a profile).
22
+ //
23
+ // DB-free (db monkeypatched — the sibling board tests' idiom).
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const GOV = path.join(ROOT, 'modules', 'government');
33
+ const board = require(path.join(GOV, 'board.js'));
34
+ const db = require(path.join(GOV, 'db.js'));
35
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
36
+
37
+ async function withDb(stubs, fn) {
38
+ const originals = {};
39
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
40
+ try { return await fn(); }
41
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
42
+ }
43
+
44
+ const ARCHON = { membership: 'rank:archon', pass_rule: 'first_ratifier' };
45
+
46
+ // One open item. author_id/already_voted are what the SQL hands back.
47
+ const item = (id, { author = null, voted = false, constitution = ARCHON } = {}) => ({
48
+ id: String(id),
49
+ subject_type: 'full_idea',
50
+ subject_id: String(1000000 + Number(id)),
51
+ constitution,
52
+ opened_at: `2026-08-2${(Number(id) % 9) + 1}T00:00:00Z`,
53
+ closes_at: null,
54
+ author_id: author == null ? null : String(author),
55
+ already_voted: voted,
56
+ });
57
+
58
+ // ── rule 2: the live symptom — author sees nothing, a peer sees all nine ─────
59
+
60
+ test('the author of every open item is summoned to none of them', async () => {
61
+ // The exact live shape: 9 items, all authored by builder 1000025, membership
62
+ // rank:archon, and 1000025 IS an archon. The vote route would refuse each one
63
+ // (anti_self_vote), so the roster must not claim any are waiting on them.
64
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
65
+ await withDb({
66
+ openBoardItemsAwaitingVoter: async () => items,
67
+ builderHoldsRankActive: async () => true, // they really are an archon
68
+ }, async () => {
69
+ const out = await board.pendingVotesFor(1000025);
70
+ assert.equal(out.count, 0, 'the author must be summoned to zero of their own sittings');
71
+ assert.equal(out.first_item_id, null);
72
+ });
73
+ });
74
+
75
+ test('a peer archon who authored none of them is summoned to all nine', async () => {
76
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
77
+ await withDb({
78
+ openBoardItemsAwaitingVoter: async () => items,
79
+ builderHoldsRankActive: async () => true,
80
+ }, async () => {
81
+ const out = await board.pendingVotesFor(1000094); // scootermack, not the author
82
+ assert.equal(out.count, 9);
83
+ // Oldest first — the sitting that has waited longest is the one to answer.
84
+ assert.equal(out.first_item_id, '1');
85
+ });
86
+ });
87
+
88
+ // ── rule 1: the count matches what the route would accept ────────────────────
89
+
90
+ test('an already-cast vote is not counted again', async () => {
91
+ await withDb({
92
+ openBoardItemsAwaitingVoter: async () => [
93
+ item(1, { author: 7, voted: true }),
94
+ item(2, { author: 7, voted: false }),
95
+ ],
96
+ builderHoldsRankActive: async () => true,
97
+ }, async () => {
98
+ const out = await board.pendingVotesFor(1);
99
+ assert.equal(out.count, 1, 'one vote each — a cast item is settled for this voter');
100
+ assert.equal(out.first_item_id, '2');
101
+ });
102
+ });
103
+
104
+ test('a non-member is summoned to nothing, however many items are open', async () => {
105
+ await withDb({
106
+ openBoardItemsAwaitingVoter: async () => [item(1, { author: 7 }), item(2, { author: 7 })],
107
+ builderHoldsRankActive: async () => false, // not an archon
108
+ }, async () => {
109
+ const out = await board.pendingVotesFor(999);
110
+ assert.equal(out.count, 0);
111
+ });
112
+ });
113
+
114
+ test('an item with no author at all stays votable (nobody to disqualify)', async () => {
115
+ await withDb({
116
+ openBoardItemsAwaitingVoter: async () => [item(1, { author: null })],
117
+ builderHoldsRankActive: async () => true,
118
+ }, async () => {
119
+ assert.equal((await board.pendingVotesFor(1000025)).count, 1);
120
+ });
121
+ });
122
+
123
+ // ── rule 3: membership is per-item, and resolved once per predicate ──────────
124
+
125
+ test('membership comes from the ITEM snapshot, not one global answer', async () => {
126
+ // Two items under DIFFERENT constitutions: this builder sits on one board only.
127
+ const metic = { membership: 'rank:metic+', pass_rule: 'first_ratifier' };
128
+ await withDb({
129
+ openBoardItemsAwaitingVoter: async () => [
130
+ item(1, { author: 7, constitution: ARCHON }),
131
+ item(2, { author: 7, constitution: metic }),
132
+ ],
133
+ builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('metic'),
134
+ }, async () => {
135
+ const out = await board.pendingVotesFor(1000027);
136
+ assert.equal(out.count, 1, 'only the sitting whose snapshot enfranchises them');
137
+ assert.equal(out.first_item_id, '2');
138
+ });
139
+ });
140
+
141
+ test('one membership resolve per DISTINCT predicate, not one per item', async () => {
142
+ let calls = 0;
143
+ const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 7 }));
144
+ await withDb({
145
+ openBoardItemsAwaitingVoter: async () => items,
146
+ builderHoldsRankActive: async () => { calls++; return true; },
147
+ }, async () => {
148
+ const out = await board.pendingVotesFor(5);
149
+ assert.equal(out.count, 9);
150
+ assert.equal(calls, 1, 'nine items under one constitution cost ONE resolve');
151
+ });
152
+ });
153
+
154
+ // ── rule 4: the two failure directions ──────────────────────────────────────
155
+
156
+ test('an unreadable membership predicate summons nobody (fail-closed)', async () => {
157
+ await withDb({
158
+ openBoardItemsAwaitingVoter: async () => [
159
+ item(1, { author: 7, constitution: { membership: 'not a predicate' } }),
160
+ ],
161
+ // Would say yes if it were ever asked — the parse must refuse first.
162
+ builderHoldsRankActive: async () => true,
163
+ }, async () => {
164
+ assert.equal((await board.pendingVotesFor(1)).count, 0);
165
+ });
166
+ });
167
+
168
+ test('a DB error yields a zero count, never a throw (the /me hot path)', async () => {
169
+ await withDb({
170
+ openBoardItemsAwaitingVoter: async () => { throw new Error('boom'); },
171
+ }, async () => {
172
+ const out = await board.pendingVotesFor(1);
173
+ assert.equal(out.count, 0);
174
+ assert.equal(out.first_item_id, null);
175
+ });
176
+ });
177
+
178
+ test('a missing builder id is answered without touching the database', async () => {
179
+ await withDb({
180
+ openBoardItemsAwaitingVoter: async () => { throw new Error('must not be called'); },
181
+ }, async () => {
182
+ for (const id of [null, undefined, '']) {
183
+ assert.equal((await board.pendingVotesFor(id)).count, 0);
184
+ }
185
+ });
186
+ });
187
+
188
+ // ── the need the count feeds ─────────────────────────────────────────────────
189
+
190
+ test('zero pending votes produces NO need row', () => {
191
+ // An empty state is not an input the system needs from you — the sibling
192
+ // collab needs take the same line.
193
+ assert.equal(needs.boardVotesNeed({}), null);
194
+ assert.equal(needs.boardVotesNeed({ pendingBoardVotes: { count: 0 } }), null);
195
+ assert.equal(needs.boardVotesNeed({ pendingBoardVotes: null }), null);
196
+ });
197
+
198
+ test('pending votes are action_needed and deep-link the oldest sitting', () => {
199
+ const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
200
+ assert.equal(n.id, 'board_votes');
201
+ assert.equal(n.state, 'action_needed');
202
+ assert.equal(n.action.href, '/government?item=1#board-room');
203
+ // The hash is the TAB (makeTabs owns it); the item is a query param.
204
+ assert.match(n.action.href, /#board-room$/);
205
+ });
206
+
207
+ test('a count with no item id still links the room', () => {
208
+ const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
209
+ assert.equal(n.action.href, '/government#board-room');
210
+ });
211
+
212
+ test('the need reaches computeNeeds as an action_needed item', () => {
213
+ const out = needs.computeNeeds({
214
+ rank: 'archon', ownKeySet: true,
215
+ pendingBoardVotes: { count: 3, first_item_id: '4' },
216
+ });
217
+ const row = out.items.find((i) => i.id === 'board_votes');
218
+ assert.ok(row, 'the registry must carry the new need');
219
+ assert.equal(out.has_action_needed, true);
220
+ // action_needed sorts first, so a renderer taking "the first one" gets it.
221
+ assert.equal(out.items[0].id, 'board_votes');
222
+ });