@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.
- package/.bongos-core.json +71 -26
- package/docs/adr/0191-majority-and-the-author-who-may-not-decide-alone.md +142 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +61 -61
- package/docs/copy-registry.json +64 -64
- package/docs/file-map.md +13 -1
- package/modules/builder-settings/event-sound-prefs.js +14 -4
- package/modules/government/board.js +199 -31
- package/modules/government/config.js +26 -5
- package/modules/government/routes/government.js +6 -1
- package/modules/hall-ui/public/alert.wav +0 -0
- package/modules/hall-ui/public/fanfare.wav +0 -0
- package/modules/hall-ui/public/government.js +27 -7
- package/modules/hall-ui/public/ship.wav +0 -0
- package/modules/hall-ui/public/triumph.wav +0 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-sounds.js +176 -0
- package/scripts/gds/ship-io.js +16 -2
- package/scripts/gds/turn-end-sound.js +74 -0
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +37 -8
- package/tests/government_board_close.mjs +13 -2
- package/tests/government_board_majority.mjs +276 -0
- package/tests/government_board_vote.mjs +35 -4
- package/tests/government_board_vote_ui.mjs +18 -3
- package/tests/government_config.mjs +9 -2
- package/tests/sound_player.mjs +345 -0
|
@@ -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 }
|
|
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' |
|
|
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
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
489
|
+
const constitution = item.constitution || {};
|
|
361
490
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
|
Binary file
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
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.
|
|
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.
|
|
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 };
|