@bongos/core 1.19.364 → 1.19.366

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.
@@ -214,10 +214,13 @@ function validateVote({ direction, reasonMd, sectionKey } = {}) {
214
214
 
215
215
  // Cast a vote on an open item. Returns a typed result, never throws for a
216
216
  // policy refusal — the route maps each to its status:
217
- // { ok: true, vote, fresh: true, karma_delta } — recorded now
218
- // { ok: true, vote, fresh: false } — idempotent re-cast
217
+ // { ok: true, vote, fresh: true, karma_delta, self_cast } — recorded now
218
+ // { ok: true, vote, fresh: false, self_cast } — idempotent re-cast
219
219
  // { ok: false, reason: 'item_not_found' | 'item_closed' | 'vote_window_expired'
220
- // | 'not_board_member' | 'anti_self_vote' | <validateVote reason> }
220
+ // | 'not_board_member' | <validateVote reason> }
221
+ // 'anti_self_vote' is GONE as of task 1003273 — an author may cast; what they
222
+ // cannot do is decide alone, which shouldCloseAfterVote and tallyOutcome enforce.
223
+ // A self-cast is recorded and visible but earns no karma (karma_delta: 0).
221
224
  async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null } = {}) {
222
225
  const id = Number(itemId);
223
226
  if (!Number.isFinite(id) || id <= 0) return { ok: false, reason: 'item_not_found' };
@@ -237,20 +240,34 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
237
240
  const constitution = item.constitution || {};
238
241
  if (!(await isBoardMember(voterId, constitution))) return { ok: false, reason: 'not_board_member' };
239
242
 
240
- // ANTI-SELF IS A FULL-IDEA RULE, NOT AN AMENDMENT RULE (R14, ADR 0175 §§7/11).
241
- // The bright line exists because an idea's author GAINS from ratification
242
- // (credits, legitimacy) — ceremonial self-ratification is what it prevents.
243
- // An amendment pays nothing, and §7 explicitly blesses the monarchy act:
244
- // under a one-Archon board with no clock, the proposer must be able to
245
- // ratify their own amendment or the subject is dead on arrival — and adding
246
- // a special escape for that case is the bootstrap exception §7 forbids.
247
- // Skipping anti-self FOR AMENDMENTS is the rule that needs no exceptions.
248
- if (item.subject_type === 'full_idea') {
249
- const authorId = await db.getBoardItemAuthor(item);
250
- if (authorId != null && String(authorId) === String(voterId)) {
251
- return { ok: false, reason: 'anti_self_vote' };
252
- }
253
- }
243
+ // THE AUTHOR MAY NOW VOTE (task 1003273, owner decision 2026-08-25). The old
244
+ // rule refused an author's cast outright, which is defensible on a board of
245
+ // strangers and dead-ended here: every open item was authored by the one member
246
+ // reading them, so the room offered nine sittings and no ballot.
247
+ //
248
+ // WHAT REPLACES IT IS NOT A WEAKER RULE, IT IS A RULE IN A DIFFERENT PLACE.
249
+ // "An author never decides alone" is enforced where deciding actually happens —
250
+ // shouldCloseAfterVote (an author's cast never ends the sitting) and
251
+ // tallyOutcome (a majority with no non-author yes cannot carry, and an author's
252
+ // 'no' cannot return their own idea). Refusing the CAST was only ever a proxy
253
+ // for those, and it was the proxy that produced the deadlock.
254
+ //
255
+ // So there is no anti-self branch here any more, for either subject type. The
256
+ // amendment carve-out ADR 0175 §§7/11 blessed is subsumed rather than removed:
257
+ // a sole Archon proposing an amendment could always ratify it, and still can —
258
+ // that path never had a non-author to wait for, and an amendment pays nothing,
259
+ // which is why it was carved out in the first place.
260
+ //
261
+ // NO KARMA FOR VOTING ON YOUR OWN ITEM. The +1 exists to reward the work of
262
+ // reading someone else's idea and taking a position on it; paying it for a
263
+ // self-cast would make an author's own ballot a tiny income stream, and the
264
+ // owner's rule is that a self-vote confers no advantage.
265
+ // Not even asked for an amendment — the carve-out means there is nothing to
266
+ // learn, and the read is a query on the vote path.
267
+ const subjectAuthorId = authorRuleApplies(item.subject_type)
268
+ ? await db.getBoardItemAuthor(item)
269
+ : null;
270
+ const isSelfCast = subjectAuthorId != null && String(subjectAuthorId) === String(voterId);
254
271
 
255
272
  const inserted = await db.insertBoardVote({
256
273
  itemId: id, voterId, direction: v.direction, reasonMd: v.reasonMd, sectionKey: v.sectionKey,
@@ -258,12 +275,15 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
258
275
  if (!inserted) {
259
276
  // UNIQUE(item_id, voter_id) — the idempotent path. Return what stands.
260
277
  const existing = await db.getBoardVote(id, voterId);
261
- return { ok: true, vote: existing, fresh: false };
278
+ return { ok: true, vote: existing, fresh: false, self_cast: isSelfCast };
262
279
  }
263
280
  // Karma rides the FRESH insert only, and the whole route refused a closed or
264
281
  // expired item above, so every paid vote is by construction in-window.
282
+ if (isSelfCast) {
283
+ return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast: true };
284
+ }
265
285
  await db.insertVoteKarma({ voterId, itemId: id, delta: VOTE_KARMA_DELTA });
266
- return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA };
286
+ return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA, self_cast: false };
267
287
  }
268
288
 
269
289
  // ── close + tally (BV1.R10 of goal 1000069, ADR 0175 §3) ────────────────────
@@ -299,19 +319,128 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
299
319
  // `WHERE outcome = 'passed'` shape survives precisely because closing and
300
320
  // emitting are separate acts.
301
321
 
302
- // The whole counting rule. Absence is nulled by what this function DOESN'T
303
- // read: only cast votes exist here.
304
- function tallyOutcome(votes) {
305
- return (votes || []).some((v) => v.direction === 'no') ? 'returned' : 'passed';
322
+ // ── the author's own vote (task 1003273) ────────────────────────────────────
323
+ //
324
+ // THE RULE THE OWNER CHOSE, and the deadlock it replaces. The old line was flat:
325
+ // an author could not vote on their own idea at all. That is defensible on a
326
+ // board of strangers and produced a dead end here — every open item was authored
327
+ // by the one person looking at them, so the Board Room showed nine sittings and
328
+ // no ballot. The rule now: AN AUTHOR MAY VOTE, AND MAY NEVER DECIDE ALONE.
329
+ //
330
+ // "Never decide alone" is enforced in two places, because there are two ways to
331
+ // decide something:
332
+ // 1. by CLOSING it — shouldCloseAfterVote requires a NON-AUTHOR vote, so
333
+ // an author's cast never trips the trigger that ends the sitting;
334
+ // 2. by CARRYING it — the majority tally requires at least one non-author
335
+ // YES, so a majority made only of the author cannot ratify.
336
+ // An author's vote still counts toward the majority NUMERATOR (it is a real
337
+ // vote — they sit on the board), it just can never be the whole of it.
338
+ //
339
+ // AND AN AUTHOR CAN NEVER MAKE THEIR OWN IDEA WORSE. Their 'no' is excluded from
340
+ // the objection test, so voting on your own idea cannot return it — otherwise the
341
+ // safest thing an author could do would be to stay silent, which is a strange
342
+ // incentive to build into a board.
343
+ // THE AUTHOR RULE IS A FULL_IDEA RULE, and that subject-typing is load-bearing
344
+ // rather than tidy (ADR 0175 §§7/11). An idea's author GAINS from ratification
345
+ // (credits, karma, legitimacy), which is what "never decide alone" guards. An
346
+ // amendment pays nothing, and §7 explicitly blesses the monarchy act: on a
347
+ // one-Archon board the proposer must be able to ratify their own amendment or
348
+ // the whole subject type is dead on arrival — and a special escape for that case
349
+ // is precisely the bootstrap exception §7 forbids. So the carve-out is not an
350
+ // exception to the author rule, it is the author rule's scope.
351
+ //
352
+ // Everything downstream reads the author through this: for an amendment the
353
+ // deciding author is NULL, so the trigger and the tally behave exactly as they
354
+ // did before task 1003273.
355
+ const authorRuleApplies = (subjectType) => subjectType === 'full_idea';
356
+
357
+ // The author id that the DECIDING rules should see: the real one for a full
358
+ // idea, nobody for an amendment.
359
+ const decidingAuthorId = (subjectType, authorId) =>
360
+ (authorRuleApplies(subjectType) ? authorId : null);
361
+
362
+ const isAuthorVote = (v, authorId) =>
363
+ authorId != null && String(v.voter_id) === String(authorId);
364
+
365
+ // Votes that can DECIDE: everyone but the subject's author.
366
+ const decidingVotes = (votes, authorId) =>
367
+ (votes || []).filter((v) => !isAuthorVote(v, authorId));
368
+
369
+ // Strictly more than half — never >=. On an even board of 4 that is 3, not 2:
370
+ // "more than half" is the honest reading of majority, and a tie is not a
371
+ // majority. Of the resolved MEMBERSHIP, not of the votes cast, so low turnout
372
+ // cannot quietly pass something on two voices out of nine.
373
+ function hasMajority(yesCount, memberCount) {
374
+ if (!Number.isFinite(memberCount) || memberCount <= 0) return false;
375
+ return yesCount * 2 > memberCount;
376
+ }
377
+
378
+ // The whole counting rule. Under the two absence-passes rules, absence is nulled
379
+ // by what this function DOESN'T read: only cast votes exist here. Under
380
+ // `majority` absence is the opposite — it is counted, via the membership
381
+ // denominator — which is exactly what makes silence not assent.
382
+ function tallyOutcome(votes, { constitution = {}, members = null, authorId = null } = {}) {
383
+ const list = votes || [];
384
+ if (constitution.pass_rule === 'majority') {
385
+ const yes = list.filter((v) => v.direction === 'yes');
386
+ const memberCount = Array.isArray(members) ? members.length : 0;
387
+ // Both conditions, or it returns: a majority of the membership said yes, AND
388
+ // at least one voice that was not the author's is among them.
389
+ const carried = hasMajority(yes.length, memberCount)
390
+ && yes.some((v) => !isAuthorVote(v, authorId));
391
+ return carried ? 'passed' : 'returned';
392
+ }
393
+ // first_ratifier / consent / anything unknown: one reasoned objection returns
394
+ // the idea, anything else — including ZERO votes — passes. The author's own
395
+ // vote is excluded so they cannot return their own idea.
396
+ return decidingVotes(list, authorId).some((v) => v.direction === 'no') ? 'returned' : 'passed';
397
+ }
398
+
399
+ // Is this majority sitting already DECIDED, whatever the absent members do? Once
400
+ // the yes votes clear the bar the outcome cannot change (there is no
401
+ // vote-changing in v1), and once enough members have voted the OTHER way that the
402
+ // remaining ones cannot reach the bar, it cannot change either. Closing then is
403
+ // not impatience — it is refusing to hold a sitting whose answer is already
404
+ // fixed, which is the same reason consent closes on full turnout.
405
+ function majorityDecided({ votes, members, authorId }) {
406
+ const list = votes || [];
407
+ const memberCount = Array.isArray(members) ? members.length : 0;
408
+ if (!memberCount) return false; // an empty board decides nothing
409
+ const yes = list.filter((v) => v.direction === 'yes');
410
+ // Already carried — but only if a non-author yes is among them, since a
411
+ // majority that is only the author can still be joined by someone who makes
412
+ // it legitimate. Closing early there would deny the item its one real voice.
413
+ if (hasMajority(yes.length, memberCount) && yes.some((v) => !isAuthorVote(v, authorId))) return true;
414
+ // Already unreachable: even if every member who has not voted votes yes, the
415
+ // bar cannot be met. A member who voted 'no' cannot vote again (v1).
416
+ const voted = new Set(list.map((v) => String(v.voter_id)));
417
+ const notYetVoted = members.filter((m) => !voted.has(String(m.builder_id))).length;
418
+ return !hasMajority(yes.length + notYetVoted, memberCount);
306
419
  }
307
420
 
308
421
  // Should a just-cast vote close the item now, under the SNAPSHOTTED rule?
309
- function shouldCloseAfterVote({ constitution, votes, members } = {}) {
422
+ //
423
+ // EVERY RULE NEEDS A NON-AUTHOR VOTE FIRST (task 1003273): an author's cast is
424
+ // recorded but never ends the sitting. Without this an author could close their
425
+ // own item under first_ratifier — the very self-decision the rule forbids.
426
+ function shouldCloseAfterVote({ constitution, votes, members, authorId = null } = {}) {
310
427
  const rule = (constitution || {}).pass_rule;
311
- if (rule === 'first_ratifier') return (votes || []).length >= 1;
428
+ const deciding = decidingVotes(votes, authorId);
429
+ if (rule === 'first_ratifier') return deciding.length >= 1;
430
+ if (rule === 'majority') {
431
+ if (!deciding.length) return false; // nobody but the author has spoken
432
+ if (majorityDecided({ votes, members, authorId })) return true;
433
+ // Full turnout closes it too, when the constitution says so — the same
434
+ // courtesy consent gets, and by then every position is on the record.
435
+ if (!(constitution || {}).close_early_on_full_turnout) return false;
436
+ if (!members || !members.length) return false;
437
+ const voters = new Set((votes || []).map((v) => String(v.voter_id)));
438
+ return members.every((m) => voters.has(String(m.builder_id)));
439
+ }
312
440
  if (rule === 'consent') {
313
441
  if (!(constitution || {}).close_early_on_full_turnout) return false;
314
442
  if (!members || !members.length) return false; // an empty board has no turnout to complete
443
+ if (!deciding.length) return false;
315
444
  const voters = new Set((votes || []).map((v) => String(v.voter_id)));
316
445
  return members.every((m) => voters.has(String(m.builder_id)));
317
446
  }
@@ -357,15 +486,46 @@ function passPayload(closed, { authorId, completenessScore, passedAt }) {
357
486
  async function closeItem(item, { cause = 'vote' } = {}) {
358
487
  if (!item || item.closed_at) return { closed: false, reason: 'already_closed' };
359
488
  const votes = await db.getVotesForItem(item.id);
360
- const outcome = tallyOutcome(votes);
489
+ const constitution = item.constitution || {};
361
490
 
362
- // The payload's reads, ahead of the transaction (reads never ride a tx they
363
- // don't need). Only a pass needs them, and only a full_idea can pay.
364
- let authorId = null;
491
+ // THE TALLY'S INPUTS ARE READ HERE, NOT PASSED IN, so the two callers — a
492
+ // racing vote and the expiry sweep — cannot disagree about what a close means
493
+ // (the invariant R10 already held for the outcome now has to hold for the
494
+ // majority denominator too). Both are resolved against the item's SNAPSHOT and
495
+ // the live rows, uncached, the same ADR 0016 posture as every other read here.
496
+ //
497
+ // authorId moved AHEAD of the tally (task 1003273): it used to be fetched only
498
+ // on a pass, for the payload, but the author rule makes it an INPUT to the
499
+ // outcome — an author's vote must not return their own idea, and a majority
500
+ // made only of the author must not carry it.
501
+ // Read ONLY when a decision actually depends on it. For a full idea the author
502
+ // is an INPUT to the outcome (their 'no' cannot return their own idea, and a
503
+ // majority with no other voice cannot carry it), so it must be known before the
504
+ // tally. For an amendment the carve-out makes it irrelevant to the outcome, so
505
+ // the read stays where it always was — after the tally, only on a pass, for the
506
+ // payload and the ratification karma. Same query count as before this task on
507
+ // the amendment path, one extra on an idea close.
508
+ let authorId = authorRuleApplies(item.subject_type)
509
+ ? await db.getBoardItemAuthor(item)
510
+ : null;
511
+ // Only `majority` needs the membership denominator; the absence-passes rules
512
+ // read nothing but the cast votes, so we do not spend the query on them.
513
+ const members = constitution.pass_rule === 'majority'
514
+ ? await resolveBoardMembers(constitution)
515
+ : null;
516
+ const outcome = tallyOutcome(votes, {
517
+ constitution, members,
518
+ // Subject-typed: an amendment's proposer is not held to the author rule (§7).
519
+ authorId: decidingAuthorId(item.subject_type, authorId),
520
+ });
521
+
522
+ // The payload's remaining reads (reads never ride a tx they don't need). Only a
523
+ // pass needs them, and only a full_idea can pay.
365
524
  let completenessScore = null;
366
525
  let credit = null;
367
526
  if (outcome === 'passed') {
368
- authorId = await db.getBoardItemAuthor(item);
527
+ // The amendment path's author, deferred to here — see the note above.
528
+ if (authorId == null) authorId = await db.getBoardItemAuthor(item);
369
529
  if (item.subject_type === 'full_idea') {
370
530
  completenessScore = await db.getIdeaCompleteness(item.subject_id);
371
531
  }
@@ -444,7 +604,12 @@ async function maybeCloseAfterVote(itemId) {
444
604
  const constitution = item.constitution || {};
445
605
  const votes = await db.getVotesForItem(item.id);
446
606
  const members = await resolveBoardMembers(constitution);
447
- if (!shouldCloseAfterVote({ constitution, votes, members })) {
607
+ // task 1003273: the author is an input to the trigger, not just the tally — an
608
+ // author's cast must never be the vote that ends the sitting.
609
+ const authorId = authorRuleApplies(item.subject_type)
610
+ ? await db.getBoardItemAuthor(item)
611
+ : null;
612
+ if (!shouldCloseAfterVote({ constitution, votes, members, authorId })) {
448
613
  return { closed: false, reason: 'window_still_open' };
449
614
  }
450
615
  return closeItem(item, { cause: 'vote' });
@@ -787,6 +952,9 @@ async function pendingVotesFor(builderId) {
787
952
 
788
953
  module.exports = {
789
954
  pendingVotesFor,
955
+ hasMajority,
956
+ majorityDecided,
957
+ decidingVotes,
790
958
  FULL_IDEA_PASS_BAR,
791
959
  ideaPassesBar,
792
960
  openWindowForFullIdea,
@@ -141,6 +141,13 @@ function sanitizeRole(key, raw) {
141
141
  // predicate says which RANK governs; who actually holds that rank stays a DB row,
142
142
  // resolved per request by R06's membership resolver. Nothing here names a builder,
143
143
  // and a config edit cannot make anyone a board member.
144
+ // WHY window_minutes STAYS null HERE (task 1003273). A clock is only safe under
145
+ // a rule where expiry means RETURN. Under first_ratifier and consent the tally
146
+ // passes on absence, so giving a sitting a deadline would make the expiry sweep
147
+ // ratify items NOBODY READ — paying author karma and the idea credit on them.
148
+ // A project that wants a clock wants `majority` first, and sets both together in
149
+ // its own instance pack; the neutral default must not hand every instance an
150
+ // auto-ratifier.
144
151
  const BOARD_DEFAULTS = Object.freeze({
145
152
  membership: 'rank:archon',
146
153
  pass_rule: 'first_ratifier',
@@ -148,11 +155,25 @@ const BOARD_DEFAULTS = Object.freeze({
148
155
  close_early_on_full_turnout: true,
149
156
  });
150
157
 
151
- // The pass rules that EXIST, not the ones a project should pick. `consent` is the
152
- // documented recommendation once a project has a board worth sitting (all Archons,
153
- // one vote each, consent, a 90-minute window); `first_ratifier` is the day-one
154
- // default and what an unreadable value falls back to.
155
- const PASS_RULES = Object.freeze(['first_ratifier', 'consent']);
158
+ // The pass rules that EXIST, not the ones a project should pick. `first_ratifier`
159
+ // is the day-one default and what an unreadable value falls back to.
160
+ //
161
+ // THE THREE ARE NOT A LADDER OF STRICTNESS — they differ in what SILENCE means,
162
+ // which is the axis that actually matters (task 1003273):
163
+ // • first_ratifier — one affirmative act decides. Silence passes (the tally
164
+ // only returns on a reasoned objection), so an unvoted item ratifies.
165
+ // • consent — the same tally over a sitting; an empty window passes
166
+ // UNOPPOSED. Silence is assent, deliberately (owner decision 6).
167
+ // • majority — strictly more than half of the RESOLVED MEMBERSHIP must
168
+ // vote yes. Silence is NOT assent: an item that never reaches a majority
169
+ // RETURNS at expiry. This is the only rule under which not voting is a
170
+ // meaningful act, and the only one where a clock is safe — see the note on
171
+ // window_minutes below.
172
+ // Adding `majority` cannot widen the fail-closed fallback: sanitizeBoard still
173
+ // falls back to first_ratifier, and majority is the STRICTER end of this axis
174
+ // anyway (it demands affirmative votes from a majority, where the other two
175
+ // pass on absence).
176
+ const PASS_RULES = Object.freeze(['first_ratifier', 'consent', 'majority']);
156
177
 
157
178
  // A membership predicate names WHO SITS. The grammar and its parser live in
158
179
  // ./membership-predicate — ONE implementation, shared with R06's resolver
@@ -540,7 +540,12 @@ module.exports = function buildGovernmentRouter() {
540
540
  const status =
541
541
  out.reason === 'item_not_found' ? 404 :
542
542
  out.reason === 'not_board_member' ? 403 :
543
- out.reason === 'anti_self_vote' ? 403 :
543
+ // 'anti_self_vote' was retired by task 1003273 — an author may cast;
544
+ // what they cannot do is decide alone, which the close trigger and the
545
+ // tally enforce rather than the route. The mapping is left OUT rather
546
+ // than kept "just in case": a reason castVote can no longer return
547
+ // would be dead code pretending to be a wall.
548
+
544
549
  out.reason === 'item_closed' ? 409 :
545
550
  out.reason === 'vote_window_expired' ? 409 :
546
551
  400; // bad_direction / yes_carries_nothing / objection_needs_reason / objection_needs_section
Binary file
@@ -89,7 +89,6 @@
89
89
  validation_failed: 'Missing or invalid fields.',
90
90
  // the vote refusals (R17 over R08's route)
91
91
  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.',
92
- anti_self_vote: 'You can\u2019t vote on your own idea \u2014 that\u2019s the line that keeps ratification honest.',
93
92
  item_closed: 'This sitting has closed \u2014 refreshing.',
94
93
  vote_window_expired: 'This sitting\u2019s clock has run out \u2014 the sweep is closing it.',
95
94
  objection_needs_reason: 'An objection must say why \u2014 write the reason.',
@@ -519,6 +518,14 @@
519
518
  if (board.pass_rule === 'consent') {
520
519
  return 'Items pass by <strong>consent</strong>: silence is consent, a single reasoned objection returns the item to its author, and a sitting that ends with no votes at all passes.';
521
520
  }
521
+ // task 1003273. The sentence leads with what silence means, because that is
522
+ // the axis these three rules actually differ on and the one that surprises
523
+ // people: under the other two an unvoted item passes, under this one it does not.
524
+ if (board.pass_rule === 'majority') {
525
+ return 'Items pass by <strong>majority</strong>: more than half of the board must vote yes \u2014 not just more yes than no. '
526
+ + 'Silence is <em>not</em> agreement here, so an item that never reaches a majority is returned to its author. '
527
+ + 'An author may vote on their own idea, but it can never be carried by their vote alone.';
528
+ }
522
529
  return `Pass rule: <code>${escapeHtml(String(board.pass_rule || 'unset'))}</code>.`;
523
530
  }
524
531
 
@@ -694,9 +701,12 @@
694
701
  return rem || 'timed sitting';
695
702
  }
696
703
  const rule = item.constitution && item.constitution.pass_rule;
697
- return rule === 'first_ratifier'
698
- ? 'no clock — closes on the first vote'
699
- : 'no clock';
704
+ if (rule === 'first_ratifier') return 'no clock — closes on the first vote';
705
+ // A clockless majority sitting is worth naming honestly: without a deadline
706
+ // it waits indefinitely for a majority, which is a real state a project can
707
+ // configure itself into (task 1003273).
708
+ if (rule === 'majority') return 'no clock — waits for a majority';
709
+ return 'no clock';
700
710
  }
701
711
 
702
712
  function ballotHtml(item, viewerId) {
@@ -750,12 +760,22 @@
750
760
  if (item.closed_at) return '';
751
761
  const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
752
762
  if (mine) return '';
753
- if (item.subject_type === 'full_idea' && item.subject && String(item.subject.author_id) === String(viewerId)) {
754
- 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>`;
755
- }
763
+ // TASK 1003273: the author gets a REAL ballot. The old branch returned a note
764
+ // and no form, which is what made the Board Room show nine sittings and no
765
+ // way to vote on any of them — every one of them was the reader's own. They
766
+ // may now cast; what they cannot do is decide alone (the close trigger needs
767
+ // a voice that is not theirs, and a majority made only of them cannot carry).
768
+ // The note says so instead of refusing, so the limit is visible rather than
769
+ // discovered by clicking.
770
+ const isOwnIdea = item.subject_type === 'full_idea'
771
+ && item.subject && String(item.subject.author_id) === String(viewerId);
756
772
  const sections = Object.keys(SECTION_LABELS).map((key) => `
757
773
  <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('');
774
+ const ownNote = isOwnIdea
775
+ ? `<p class="gov-grid-note">This is your idea. You can vote on it \u2014 your position belongs on the record \u2014 but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
776
+ : '';
758
777
  return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
778
+ ${ownNote}
759
779
  <button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
760
780
  <details class="gov-vote__no">
761
781
  <summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
Binary file
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.364",
3
+ "version": "1.19.366",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.364",
9
+ "version": "1.19.366",
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.364",
3
+ "version": "1.19.366",
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",
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/gen-sounds.js — synthesize the notification WAVs (task 1003272).
3
+ //
4
+ // WHY A GENERATOR RATHER THAN FOUR COMMITTED BINARIES. The 5-event sound system
5
+ // (V3.R90, task 1000781) shipped its configuration — the prefs, the toggles, the
6
+ // master volume, the per-event variant picker — and never shipped the audio. Only
7
+ // chime.wav has ever existed in this repo, so four of the five events resolved to
8
+ // a 404 and played nothing. Two prior attempts to fix "sound is broken" (tasks
9
+ // 1001252 and 1000796) were abandoned without reaching that.
10
+ //
11
+ // An opaque binary nobody can regenerate is how a repo loses assets in the first
12
+ // place (the local player itself vanished in the 1.18.0 re-seed and no commit
13
+ // records it). These are simple synthesized tones, so the honest artifact is the
14
+ // recipe: a few dozen lines of additive synthesis anyone can read, adjust, and
15
+ // re-run. `node scripts/gds/gen-sounds.js` is idempotent — same bytes every time,
16
+ // no timestamps, no randomness — so it can be a CI freshness check like the other
17
+ // generators if that is ever wanted.
18
+ //
19
+ // chime.wav IS NOT TOUCHED. It predates this task and is the one asset the system
20
+ // always had; regenerating it would replace a sound builders already know with a
21
+ // different one for no reason. This writes only the four that were missing.
22
+ //
23
+ // WHERE THEY GO, and why one copy. modules/hall-ui/public/ is served at
24
+ // /builders/<name>.wav, which is exactly what settings.js previews
25
+ // (`new Audio('/builders/' + wavName + '.wav')`). The local player
26
+ // (.claude/sounds/play.js) reads the SAME files from that directory rather than
27
+ // keeping a second copy — two copies of an asset are two things to drift.
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const RATE = 44100;
35
+ const OUT_DIR = path.resolve(__dirname, '..', '..', 'modules', 'hall-ui', 'public');
36
+
37
+ // Equal-temperament note table, only the notes these sounds use. Named rather
38
+ // than numeric so the voicings below read as music instead of magic numbers.
39
+ const HZ = {
40
+ A5: 880.00, D6: 1174.66,
41
+ C5: 523.25, E5: 659.25, G5: 783.99,
42
+ C6: 1046.50, E6: 1318.51, G6: 1567.98,
43
+ };
44
+
45
+ // One struck note: a fundamental plus a little second and third harmonic (a bell
46
+ // is not a sine wave), under an exponential decay with a short fade-in. The
47
+ // fade-in matters — starting a waveform at full amplitude is an audible click.
48
+ function note({ freq, start, dur, gain = 0.5, decay = 4.5 }) {
49
+ const voices = [
50
+ { mult: 1, amp: 1.0 },
51
+ { mult: 2, amp: 0.28 },
52
+ { mult: 3, amp: 0.10 },
53
+ ];
54
+ const attack = Math.min(0.006, dur / 8); // seconds
55
+ return { freq, start, dur, gain, decay, voices, attack };
56
+ }
57
+
58
+ // Render a list of notes into one mono float buffer, summed (notes may overlap).
59
+ function render(notes) {
60
+ const end = notes.reduce((m, n) => Math.max(m, n.start + n.dur), 0);
61
+ const total = Math.ceil((end + 0.03) * RATE); // a little tail so nothing cuts
62
+ const buf = new Float64Array(total);
63
+ for (const n of notes) {
64
+ const from = Math.floor(n.start * RATE);
65
+ const len = Math.floor(n.dur * RATE);
66
+ for (let i = 0; i < len; i++) {
67
+ const t = i / RATE;
68
+ const env = Math.exp(-n.decay * t) * Math.min(1, t / n.attack);
69
+ let s = 0;
70
+ for (const v of n.voices) s += v.amp * Math.sin(2 * Math.PI * n.freq * v.mult * t);
71
+ buf[from + i] += s * env * n.gain;
72
+ }
73
+ }
74
+ // Normalize to a consistent peak so no sound is jarringly louder than another,
75
+ // then a short linear fade-out so the file never ends mid-cycle (another click).
76
+ let peak = 0;
77
+ for (let i = 0; i < total; i++) peak = Math.max(peak, Math.abs(buf[i]));
78
+ const scale = peak > 0 ? 0.82 / peak : 0;
79
+ const fade = Math.min(total, Math.floor(0.02 * RATE));
80
+ for (let i = 0; i < total; i++) {
81
+ let s = buf[i] * scale;
82
+ const fromEnd = total - 1 - i;
83
+ if (fromEnd < fade) s *= fromEnd / fade;
84
+ buf[i] = s;
85
+ }
86
+ return buf;
87
+ }
88
+
89
+ // 16-bit mono PCM, 44100 Hz — byte-for-byte the format of the existing chime.wav,
90
+ // so nothing downstream has to care which file it got.
91
+ function toWav(samples) {
92
+ const dataBytes = samples.length * 2;
93
+ const out = Buffer.alloc(44 + dataBytes);
94
+ out.write('RIFF', 0);
95
+ out.writeUInt32LE(36 + dataBytes, 4);
96
+ out.write('WAVE', 8);
97
+ out.write('fmt ', 12);
98
+ out.writeUInt32LE(16, 16); // fmt chunk size
99
+ out.writeUInt16LE(1, 20); // PCM
100
+ out.writeUInt16LE(1, 22); // mono
101
+ out.writeUInt32LE(RATE, 24);
102
+ out.writeUInt32LE(RATE * 2, 28); // byte rate
103
+ out.writeUInt16LE(2, 32); // block align
104
+ out.writeUInt16LE(16, 34); // bits
105
+ out.write('data', 36);
106
+ out.writeUInt32LE(dataBytes, 40);
107
+ for (let i = 0; i < samples.length; i++) {
108
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
109
+ out.writeInt16LE(Math.round(clamped * 32767), 44 + i * 2);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ // The four voicings. Each is written to match what the event MEANS — the labels
115
+ // come from modules/builder-settings/sound-prefs.js.
116
+ const SOUNDS = {
117
+ // "When Claude needs your input" — a two-tone ask, rising so it reads as a
118
+ // question. Short and unhurried: this one can fire while someone is thinking.
119
+ alert: [
120
+ note({ freq: HZ.A5, start: 0.00, dur: 0.26, gain: 0.5, decay: 7 }),
121
+ note({ freq: HZ.D6, start: 0.15, dur: 0.40, gain: 0.5, decay: 6 }),
122
+ ],
123
+ // "When a task ships" — a settled two-note rise, C5 to G5. A fifth, because it
124
+ // should sound finished rather than expectant.
125
+ ship: [
126
+ note({ freq: HZ.C5, start: 0.00, dur: 0.30, gain: 0.5, decay: 6 }),
127
+ note({ freq: HZ.G5, start: 0.14, dur: 0.55, gain: 0.5, decay: 4.5 }),
128
+ ],
129
+ // "When a big task ships" — the same idea carried further: a major triad
130
+ // arpeggio landing on the octave.
131
+ triumph: [
132
+ note({ freq: HZ.C5, start: 0.00, dur: 0.26, gain: 0.42, decay: 7 }),
133
+ note({ freq: HZ.E5, start: 0.11, dur: 0.26, gain: 0.42, decay: 7 }),
134
+ note({ freq: HZ.G5, start: 0.22, dur: 0.30, gain: 0.46, decay: 6 }),
135
+ note({ freq: HZ.C6, start: 0.34, dur: 0.75, gain: 0.55, decay: 3.4 }),
136
+ ],
137
+ // "When you earn a rank promotion" — the brightest of the four, a four-note
138
+ // fanfare that overlaps into a held top note.
139
+ fanfare: [
140
+ note({ freq: HZ.G5, start: 0.00, dur: 0.22, gain: 0.40, decay: 8 }),
141
+ note({ freq: HZ.C6, start: 0.10, dur: 0.22, gain: 0.44, decay: 8 }),
142
+ note({ freq: HZ.E6, start: 0.20, dur: 0.26, gain: 0.46, decay: 7 }),
143
+ note({ freq: HZ.G6, start: 0.31, dur: 0.90, gain: 0.55, decay: 3.0 }),
144
+ ],
145
+ };
146
+
147
+ function main() {
148
+ const check = process.argv.includes('--check');
149
+ const written = [];
150
+ const stale = [];
151
+ for (const [name, notes] of Object.entries(SOUNDS)) {
152
+ const file = path.join(OUT_DIR, `${name}.wav`);
153
+ const wav = toWav(render(notes));
154
+ let current = null;
155
+ try { current = fs.readFileSync(file); } catch { /* absent */ }
156
+ if (current && current.equals(wav)) continue;
157
+ if (check) { stale.push(path.relative(path.resolve(__dirname, '..', '..'), file)); continue; }
158
+ fs.writeFileSync(file, wav);
159
+ written.push(`${name}.wav (${wav.length} bytes)`);
160
+ }
161
+ if (check) {
162
+ if (stale.length) {
163
+ console.error(`gen-sounds --check: STALE — would rewrite ${stale.join(', ')}`);
164
+ console.error(' run: node scripts/gds/gen-sounds.js (and commit the result)');
165
+ process.exit(1);
166
+ }
167
+ console.log('gen-sounds --check: up to date.');
168
+ return;
169
+ }
170
+ console.log(written.length
171
+ ? `gen-sounds: wrote ${written.join(', ')}`
172
+ : 'gen-sounds: all sounds already current — no changes.');
173
+ }
174
+
175
+ if (require.main === module) main();
176
+ module.exports = { render, toWav, SOUNDS, RATE };