@bongos/core 1.21.140 → 1.21.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.bongos-core.json +69 -49
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
  4. package/docs/api/openapi.json +3 -4
  5. package/docs/copy-inventory.md +107 -104
  6. package/docs/copy-registry.json +156 -129
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-inventory.json +1 -0
  9. package/docs/page-readings.json +417 -417
  10. package/modules/copy-desk/page-status.js +51 -2
  11. package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
  12. package/modules/government/db.js +6 -0
  13. package/modules/government/decision-board.js +2 -1
  14. package/modules/hall-ui/public/city-draw.js +4 -1
  15. package/modules/hall-ui/public/city.css +1 -1
  16. package/modules/hall-ui/public/city.html +3 -3
  17. package/modules/hall-ui/public/goals.js +31 -8
  18. package/modules/hall-ui/public/my-agents.html +1 -1
  19. package/modules/hall-ui/public/my-agents.js +35 -10
  20. package/modules/hall-ui/public/my-agents.states.json +1 -1
  21. package/modules/hall-ui/public/settings-interaction.js +7 -2
  22. package/modules/hall-ui/public/settings-law-locks.js +53 -0
  23. package/modules/hall-ui/public/settings.html +3 -2
  24. package/modules/hall-ui/public/settings.js +9 -4
  25. package/modules/hall-ui/public/street-deck.css +4 -0
  26. package/modules/hall-ui/public/street-office.js +73 -1
  27. package/modules/hall-ui/public/street.html +4 -4
  28. package/modules/hall-ui/public/street.js +4 -1
  29. package/modules/hall-ui/public/studio-lib.js +21 -1
  30. package/modules/hall-ui/public/studio.css +11 -0
  31. package/modules/hall-ui/public/studio.js +8 -1
  32. package/modules/hall-ui/records/living-city.md +3 -1
  33. package/modules/ideas/governor-docket.js +4 -1
  34. package/modules/lifecycle/claimable-counts.js +19 -0
  35. package/modules/lifecycle/criterion-uat-db.js +18 -1
  36. package/modules/lifecycle/criterion-uat.js +12 -3
  37. package/modules/lifecycle/routes/project-settings.js +2 -2
  38. package/modules/lifecycle/routes/tasks.js +10 -10
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +20 -0
  42. package/src/bongos/project-settings.js +5 -4
  43. package/src/module-api.js +1 -1
  44. package/tests/criterion_uat.mjs +42 -0
  45. package/tests/government_tidy_up.mjs +223 -0
  46. package/tests/hall_goals_open_first.mjs +120 -0
  47. package/tests/hall_studio_home.mjs +20 -0
@@ -260,6 +260,32 @@
260
260
  };
261
261
  }
262
262
 
263
+ // ---------- who should lead (task 1004724) ----------
264
+ // The office's "who should lead": any builder who may open a board item (the
265
+ // perm /me reports) may propose one of the project's active people as the
266
+ // building's Lead. A proposal is the existing appoint_lead People matter (POST
267
+ // /government/board/matters); only its sitting passing seats anyone, and the
268
+ // server checks the perm, the person and the building again. null: not offered.
269
+ function appointPlan(living, key, roster, me, os) {
270
+ const off = officeOf(living, key);
271
+ const perms = me && me.permissions;
272
+ if (!off || !(perms && perms['board.item.open'] === true)) return null;
273
+ const leadId = off.lead ? off.lead.id : null;
274
+ const people = (roster && Array.isArray(roster.builders) ? roster.builders : [])
275
+ .filter((b) => b && b.id != null && (b.status == null || b.status === 'active') && str(b.id) !== leadId)
276
+ .map((b) => ({ id: str(b.id), name: str(b.display_name || b.github_login) }))
277
+ .filter((p) => p.name)
278
+ .sort((a, z) => a.name.localeCompare(z.name));
279
+ if (!people.length) return null;
280
+ return { building: key, name: off.name, seated: off.seated, people: people, filed: !!(os && os.leadFiled && os.leadFiled[key]) };
281
+ }
282
+ // The body of POST /government/board/matters for one proposed Lead, or null.
283
+ function appointBody(plan, personId) {
284
+ const p = plan ? plan.people.find((x) => x.id === str(personId)) : null;
285
+ if (!p) return null;
286
+ return { area: 'people', title: 'Appoint ' + p.name + ' as Lead of ' + plan.name, action: { type: 'appoint_lead', building: plan.building, builder_id: Number(p.id) } };
287
+ }
288
+
263
289
  // ---------- how strict ----------
264
290
  // GET /government/presets (task 1004715): the preset is the one the laws in
265
291
  // force amount to (none: "No preset yet"), each carries the locks a switch to
@@ -281,6 +307,7 @@
281
307
  officeOf: officeOf, worksOf: worksOf, builtFloors: builtFloors, buildPlan: buildPlan, buildsAlone: buildsAlone,
282
308
  isoPicture: isoPicture, lawBook: lawBook, injection: injection,
283
309
  treasury: treasury, strictness: strictness, usd: usd, dayWords: dayWords,
310
+ appointPlan: appointPlan, appointBody: appointBody,
284
311
  };
285
312
 
286
313
  // =====================================================================
@@ -292,6 +319,9 @@
292
319
  const page = (function () {
293
320
  let env = null, doc = null;
294
321
  const os = blank();
322
+ // the Lead appointments this visit filed, by building (task 1004724); the
323
+ // vote itself is the server's, on the docket
324
+ const leadsFiled = {};
295
325
  const deckPage = () => (root.GovDeck ? root.GovDeck.page : null);
296
326
  const living = () => { const p = deckPage(); return p ? p.living() : null; };
297
327
  const viewerId = () => { const d = env ? env.data() : null; return d && d.docket && d.docket.viewer ? str(d.docket.viewer.id) : null; };
@@ -575,6 +605,10 @@
575
605
  off.rooms.forEach((r) => row(r.name, r.proposed ? 'proposed · a vote is open' : r.line));
576
606
  worksOf(lv, key).forEach((w) => row(w.label, w.line));
577
607
  if (rows.children && rows.children.length) el.appendChild(rows);
608
+ // who should lead: propose a Lead, filed as a People vote (task 1004724)
609
+ const fd = deckPage() ? deckPage().feeds() : {};
610
+ const ap = appointPlan(lv, key, fd.roster, fd.me, { leadFiled: leadsFiled });
611
+ if (ap) el.appendChild(appointEl(ap));
578
612
  // build out: a capsule each, one tap. Only the building's holder sees them
579
613
  // (GET /government/leads answers; the server holds the same wall).
580
614
  if (off.holders && off.holders.includes(str(viewerId()))) {
@@ -597,6 +631,31 @@
597
631
  if (c.capsule && c.ask_id) { const card = askHere(c.ask_id, { building: key, change: c.capsule }); if (card) el.appendChild(card); }
598
632
  });
599
633
  }
634
+ function appointEl(ap) {
635
+ const g = h('div', 'govx-build govx-appoint');
636
+ g.setAttribute('role', 'group');
637
+ g.setAttribute('aria-label', 'who should lead');
638
+ g.appendChild(h('span', 'desk-choices__ask', 'who should lead'));
639
+ if (ap.filed) { g.appendChild(h('span', 'govx-appoint__f', 'proposed · a vote is open')); return g; }
640
+ const sel = h('select', 'govx-appoint__who');
641
+ sel.setAttribute('aria-label', 'who should lead ' + ap.name);
642
+ ap.people.forEach((p) => { const o = h('option', '', p.name); o.setAttribute('value', p.id); sel.appendChild(o); });
643
+ g.appendChild(sel);
644
+ g.appendChild(chip('desk-chip--choice', ap.seated ? 'propose instead' : 'put it to a vote', () => appoint(ap, sel.value)));
645
+ return g;
646
+ }
647
+ // One matter, one place (task 1004724): a book's in-place ask card steps
648
+ // aside when the floor desk below already holds that matter as a row, which
649
+ // acts in place too — the treasury's dry pot used to show twice.
650
+ function settle(govt, on) {
651
+ if (!govt || !on || !govt.querySelectorAll || !on.querySelector) return 0;
652
+ let n = 0;
653
+ Array.prototype.slice.call(govt.querySelectorAll('[data-ask]')).forEach((card) => {
654
+ const id = str(card.getAttribute('data-ask')).replace(/["\\]/g, '');
655
+ if (id && on.querySelector('[data-row="' + id + '"]') && card.parentNode) { card.parentNode.removeChild(card); n++; }
656
+ });
657
+ return n;
658
+ }
600
659
  // the live site over the drawing: a tower crane over a floor going up
601
660
  function crane(bv) {
602
661
  const layer = doc.getElementById('insideFloors');
@@ -649,6 +708,19 @@
649
708
  });
650
709
  }
651
710
  // a switch of preset (task 1004715): ONE laws vote, filed through the board
711
+ function appoint(ap, personId) {
712
+ const body = appointBody(ap, personId);
713
+ if (!body || !env || !env.send) return Promise.resolve();
714
+ return env.send({ method: 'POST', url: '/api/bongos/government/board/matters', body: body }).then((out) => {
715
+ if (preview(out)) { env.say('This preview stores nothing; no vote was filed.', true); return; }
716
+ if (!out.ok) { env.say(refusal(out), true); return; }
717
+ leadsFiled[ap.building] = true;
718
+ env.say('Put to a vote. Nobody leads ' + ap.name + ' until the vote passes.', true);
719
+ if (root.GovFeeds && typeof root.GovFeeds.refresh === 'function') root.GovFeeds.refresh(['docket', 'leads']);
720
+ if (env.load) env.load();
721
+ env.draw();
722
+ });
723
+ }
652
724
  function switchPreset(to) {
653
725
  const st = strictness(living(), os);
654
726
  if (!st || !env.send) return Promise.resolve();
@@ -696,7 +768,7 @@
696
768
  p.onChange(() => { if (env && env.inside && env.inside()) env.draw(); });
697
769
  }
698
770
 
699
- return { mount: mount, input: input, inside: inside, buildOut: buildOut, switchPreset: switchPreset, state: os };
771
+ return { mount: mount, input: input, inside: inside, settle: settle, buildOut: buildOut, appoint: appoint, switchPreset: switchPreset, state: os };
700
772
  }());
701
773
 
702
774
  const api = Object.assign({ page: page }, model);
@@ -13,7 +13,7 @@
13
13
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
14
14
  <link rel="stylesheet" href="/builders/street.css?v=2026-10-09-living3">
15
15
  <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-04-record1">
16
- <link rel="stylesheet" href="/builders/street-deck.css?v=2026-10-13-glass1">
16
+ <link rel="stylesheet" href="/builders/street-deck.css?v=2026-10-17-tidy">
17
17
  </head>
18
18
  <!-- THE STREET (task 1004525, goal 1000125, R03). The street view of
19
19
  docs/design/mocks/governor-city/Street.dc.html (renderVals' street branch,
@@ -234,7 +234,7 @@
234
234
  <script src="/builders/palette.js" defer></script>
235
235
  <script src="/builders/gov-living.js?v=2026-10-09-positions1"></script>
236
236
  <script src="/builders/gov-feeds.js?v=2026-10-09-living3"></script>
237
- <script src="/builders/city-draw.js?v=2026-10-09-living3"></script>
237
+ <script src="/builders/city-draw.js?v=2026-10-17-tidy"></script>
238
238
  <script src="/builders/street-draw.js?v=2026-10-09-living3"></script>
239
239
  <script src="/builders/street-card.js?v=2026-10-04-record1"></script>
240
240
  <script src="/builders/street-inside.js?v=2026-10-09-living3"></script>
@@ -242,7 +242,7 @@
242
242
  <script src="/builders/street-desk.js?v=2026-10-04-record1"></script>
243
243
  <script src="/builders/street-amend.js?v=2026-10-02-amend1"></script>
244
244
  <script src="/builders/street-deck.js?v=2026-10-13-glass1"></script>
245
- <script src="/builders/street-office.js?v=2026-10-09-living3"></script>
246
- <script src="/builders/street.js?v=2026-10-13-glass1"></script>
245
+ <script src="/builders/street-office.js?v=2026-10-17-tidy"></script>
246
+ <script src="/builders/street.js?v=2026-10-17-tidy"></script>
247
247
  </body>
248
248
  </html>
@@ -516,7 +516,10 @@
516
516
  // the chosen floor: two blocks that take turns, so the block re-enters on every pick
517
517
  var on = $(bv.flipA ? 'insideOpenA' : 'insideOpenB'), off = $(bv.flipA ? 'insideOpenB' : 'insideOpenA');
518
518
  if (off) { off.hidden = true; clear(off); }
519
- if (on) { on.hidden = false; if (!(OP && OP.inside(bv, on, $('insideGovt')))) fillDesk(on, bv); }
519
+ if (on) {
520
+ on.hidden = false;
521
+ if (!(OP && OP.inside(bv, on, $('insideGovt')))) { fillDesk(on, bv); if (OP && OP.settle) OP.settle($('insideGovt'), on); }
522
+ }
520
523
  setText('insideOnFile', bv.onFile);
521
524
  var drawers = $('insideDrawers'); clear(drawers);
522
525
  bv.drawers.forEach(function (dw) {
@@ -151,6 +151,26 @@
151
151
  });
152
152
  }
153
153
 
154
+ // Pages in flight (task 1004662): every page with an open round, where it
155
+ // is and since when, from the server's own list (stuck first). The panel
156
+ // derives no state; it only words the age.
157
+ function inFlightRows(pages, now) {
158
+ const at = now instanceof Date ? now.getTime() : Number(now || Date.now());
159
+ return ((pages && pages.in_flight) || []).map((p) => {
160
+ const t = p.since ? Date.parse(p.since) : NaN;
161
+ const days = Number.isFinite(t) ? Math.max(0, Math.floor((at - t) / 86400000)) : null;
162
+ const age = days == null ? '' : days === 0 ? 'since today' : days === 1 ? 'for 1 day' : `for ${days} days`;
163
+ return {
164
+ page_id: p.page_id,
165
+ title: titleOf(p),
166
+ surface: surfaceWord(p.surface),
167
+ state: p.state_word || 'Needs a look',
168
+ age,
169
+ stuck: !!p.stuck,
170
+ };
171
+ });
172
+ }
173
+
154
174
  function askedLine(n) {
155
175
  const v = num(n);
156
176
  if (v <= 0) return '';
@@ -221,7 +241,7 @@
221
241
 
222
242
  const api = {
223
243
  STUDIO_PAGE, PAGE_ID_RE, SURFACE_WORDS, surfaceWord, titleOf, numberWords, tallyParts, resumeDetail,
224
- subParts, flowDetail, statusDetail, apexHost, landingHost, surfaceRows, askedLine, pickLabel, matchLine, claimWords,
244
+ subParts, flowDetail, statusDetail, apexHost, landingHost, surfaceRows, inFlightRows, askedLine, pickLabel, matchLine, claimWords,
225
245
  skyAt, dayPart, STOCK_GREETING, clockGreets,
226
246
  };
227
247
  if (typeof window !== 'undefined') window.OTBStudioLib = api;
@@ -631,6 +631,17 @@ html.st-open { overflow: hidden; }
631
631
  .st-surface__fill { height: 4px; border-radius: 999px; background: var(--st-accent); }
632
632
  .st-asked { margin: 0; font-size: 14px; line-height: 1.5; color: var(--st-ink-faint); }
633
633
 
634
+ /* Where your pages are (task 1004662): each page with an open round, its state
635
+ and how long; a stuck page leads and carries the accent. */
636
+ .st-flight__head { margin: 28px 0 10px; font-size: 13px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; color: var(--st-ink-dim); }
637
+ .st-flights { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
638
+ .st-flight { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 6px 0; }
639
+ .st-flight__page { min-height: 24px; display: inline-flex; align-items: center; font-size: 15px; color: var(--st-ink); text-decoration: none; overflow-wrap: anywhere; }
640
+ .st-flight__page:hover, .st-flight__page:focus-visible { text-decoration: underline; }
641
+ .st-flight__where { font-size: 13px; color: var(--st-ink-dim); }
642
+ .st-flight__state { margin-left: auto; font-size: 13.5px; color: var(--st-ink-soft); }
643
+ .st-flight--stuck .st-flight__state { color: var(--st-accent); }
644
+
634
645
  /* From the live site (task 1004499): one row per review, the page then who and
635
646
  when, then its opening words. Each row opens the doc in the palette's panel. */
636
647
  .st-sites { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
@@ -720,7 +720,14 @@
720
720
  <div class="st-surface__track"><div class="st-surface__fill" style="width: ${Number(s.pct)}%"></div></div>
721
721
  </li>`).join('');
722
722
  const asked = lib.askedLine(home.next && home.next.asked_pages);
723
- return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}`;
723
+ const flight = lib.inFlightRows(home.pages, new Date()).map((f) => `
724
+ <li class="st-flight${f.stuck ? ' st-flight--stuck' : ''}">
725
+ <a class="st-flight__page" href="${escapeHtml(editorHref(f.page_id))}">${escapeHtml(f.title)}</a>
726
+ <span class="st-flight__where">${escapeHtml(f.surface)}</span>
727
+ <span class="st-flight__state">${escapeHtml(f.state)}${f.age ? ` · ${escapeHtml(f.age)}` : ''}${f.stuck ? ' · stuck' : ''}</span>
728
+ </li>`).join('');
729
+ const inFlight = flight ? `<h3 class="st-flight__head">Where your pages are</h3><ul class="st-flights">${flight}</ul>` : '';
730
+ return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}${inFlight}`;
724
731
  }
725
732
 
726
733
  function siteHtml() {
@@ -45,7 +45,9 @@ The city's plaque and door, the street's header and readings, the deck and the s
45
45
 
46
46
  - **Nothing is invented.** A field comes from a read or is derived from reads by a rule in `gov-living.js`; a read that fails leaves its part out. No sample data, no prototype mark, no key.
47
47
  - **Salmon is spent only on what needs you**, everywhere: a matter routed to another seat stands in ink on the street (pin and windows) as in the city. Ordinary activity is ink, and so is a choice: inside a building the chosen floor, its frame, sign and number, the lift and the desk's plate are ink, salmon only while that floor needs you (task 1004718).
48
- - **What needs you is never hidden.** The civic quarter's back corner, centre and front corner stand in one screen column, so the Charter (the tallest) takes the back corner and nothing taller stands in front of Planning; a building's salmon windows take its top rows first (task 1004718).
48
+ - **What needs you is never hidden.** The civic quarter's back corner, centre and front corner stand in one screen column, so the Charter (the tallest) takes the back corner and nothing taller stands in front of Planning; a building's salmon windows take its top rows first (task 1004718). Task 1004724 holds the rule in every column: the taller of any two buildings in one screen column stands at the back (Release behind Rules, Automation behind People), tested over all nine.
49
+ - **One ask, one place.** Inside a building, an ask the floor desk already holds as a row (the treasury's dry pot) is not repeated as a card in the building's book above it (task 1004724).
50
+ - **Who should lead.** The Lead's office offers anyone who may open a board item a pick of the project's active people and one chip; it files the existing appoint_lead People matter (POST /government/board/matters). Only the vote passing seats a Lead; the server checks everything again (task 1004724).
49
51
  - **The street band is cropped to what it draws** (`street.js` `BAND_TOP`/`BAND_H`, matched by `street.css`): no empty sky over the skylanes and no bare ground under the brackets; a stage taller than the street needs gives its spare height to the foot of the page, under the readings, never to the sky. Paper haze is a cool sea tone deep enough to read as distance (task 1004718).
50
52
  - **Motion is CSS only**, says only "this is live", and carries no data (count, size, position and colour do). Ambient loops are 20 s or longer (the pin's halo breathes on 24 s); growth, build-out and the deck's card (the panel rises once) are class-triggered one-shots; every animation stops under `prefers-reduced-motion`, and the still frame is a complete picture.
51
53
  - **Colours are the pack's tokens**, except the iso and front-on drawing palettes, which are drawing data. Dark rules are written twice. Light mode is paper: glows become ink washes.
@@ -22,7 +22,10 @@ const blockers = require('./blockers');
22
22
  const ratify = require('./ratify');
23
23
 
24
24
  const POINT = 'governor.docket';
25
- const DOCKET_BLOCKER_LIMIT = 50;
25
+ // Every open blocker is a matter, so the docket's counts are complete (task
26
+ // 1004724: a cap of 50 undercounted past it). Open blockers are few and the read
27
+ // is one indexed query; this ceiling only guards a runaway table.
28
+ const DOCKET_BLOCKER_LIMIT = 2000;
26
29
 
27
30
  // One open blocker for one viewer. The list is public (GET /blockers), so every
28
31
  // governor sees it; only a viewer who may resolve it (`blocker.review`, the
@@ -0,0 +1,19 @@
1
+ // claimable-counts.js — how much claimable work sits in each goal (task 1004755).
2
+ //
3
+ // Backs `GET /tasks/claimable?group=goal`, which the hall Home goals card reads to say
4
+ // "N tasks you can claim" under each open goal it lists. It counts the SAME filtered
5
+ // feed the route returns rows from, so a count and claimable_total cannot disagree,
6
+ // and it returns no rows, so a Home card never downloads the feed to count it.
7
+ // A row with no goal is not counted.
8
+ 'use strict';
9
+
10
+ function claimableCountsByGoal(rows) {
11
+ const out = {};
12
+ for (const t of Array.isArray(rows) ? rows : []) {
13
+ if (!t || t.goal_id == null) continue;
14
+ out[t.goal_id] = (out[t.goal_id] || 0) + 1;
15
+ }
16
+ return out;
17
+ }
18
+
19
+ module.exports = { claimableCountsByGoal };
@@ -135,6 +135,7 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
135
135
  const readings = await readLive(before.shippedIds, deps);
136
136
  const live = uat.foldLiveReadings(readings);
137
137
  const owner = deps.ownerId !== undefined ? { id: deps.ownerId } : await api.getFoundingBuilder();
138
+ const signerIsOwner = await holdsOwnerSeat(signerId, deps);
138
139
 
139
140
  return withTx(async (client) => {
140
141
  const { rows: crit } = await client.query(
@@ -148,7 +149,7 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
148
149
  const backendOnly = criterion ? await isBackendOnly(client, id) : false;
149
150
  const refusal = uat.signoffRefusal({
150
151
  criterion, counts: now.counts, remaining: now.remaining, backendOnly, kind,
151
- signerId, ownerId: owner ? owner.id : null, shipperIds: now.shipperIds,
152
+ signerId, ownerId: owner ? owner.id : null, signerIsOwner, shipperIds: now.shipperIds,
152
153
  recording, live, liveAttested,
153
154
  });
154
155
  if (refusal) return { ok: false, refusal };
@@ -164,6 +165,22 @@ async function signOff({ criterionId, kind, signerId, recording = null, liveAtte
164
165
  }, { pool });
165
166
  }
166
167
 
168
+ // holdsOwnerSeat — does the signer hold the government's owner seat (task
169
+ // 1004757)? Read per request through the government port, uncached (ADR 0016).
170
+ // No port, or a failed read, answers false: the founding-builder check still
171
+ // applies, so this can only ever ADD the owner, never widen past them.
172
+ async function holdsOwnerSeat(signerId, deps = {}) {
173
+ const testing = process.env.NODE_ENV === 'test';
174
+ const government = testing && deps.government !== undefined ? deps.government : api.resolveOptional('government');
175
+ if (!government || typeof government.positionsFor !== 'function') return false;
176
+ try {
177
+ const p = await government.positionsFor(signerId);
178
+ return !!(p && Array.isArray(p.held) && p.held.some((h) => h && h.kind === 'owner'));
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
167
184
  // recordOverride — the /satisfy escape hatch, which now has to give a reason and
168
185
  // leaves a row saying so (the state reads "Satisfied (override)", never UAT).
169
186
  async function recordOverride(exec, { criterionId, signerId, reason }) {
@@ -27,6 +27,14 @@
27
27
  // solo project can never deadlock on its own work (the owner's "any other
28
28
  // builder or the owner").
29
29
  //
30
+ // WHO IS THE OWNER (task 1004757). The government's owner seat — every active
31
+ // Archon (modules/government/leads.js ownerOf) — is who this project calls its
32
+ // owner everywhere else (the "Owner" position on /me, the seat an unseated
33
+ // building falls to). The founding builder (lowest-id human) is kept as a
34
+ // fallback for an instance without the government module. Using ONLY the
35
+ // founding builder refused the real owner on a hub whose lowest-id human is
36
+ // someone else, while the refusal itself told them the owner could sign.
37
+ //
30
38
  // This file has NO database and NO express: the state derivation and the
31
39
  // refusal ladder are pure so every answer is pinned by a DB-free test, and the
32
40
  // store unit-tests against a temp dir. The SQL lives in criterion-uat-db.js.
@@ -125,12 +133,13 @@ function foldLiveReadings(readings) {
125
133
  // backendOnly boolean
126
134
  // kind 'uat' | 'backend_signoff'
127
135
  // signerId the authenticated builder
128
- // ownerId the project owner (getFoundingBuilder), or null
136
+ // ownerId the founding builder (getFoundingBuilder), or null
137
+ // signerIsOwner the signer holds the government's owner seat
129
138
  // shipperIds builder ids that shipped a linked task
130
139
  // recording a stored recording name, or null
131
140
  // live foldLiveReadings(...) output
132
141
  // liveAttested the signer ticked "this is on the live site"
133
- function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
142
+ function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, signerIsOwner = false, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
134
143
  if (!criterion) return { code: 'criterion_not_found', status: 404, message: 'No such criterion.' };
135
144
  if (criterion.satisfied) {
136
145
  return { code: 'criterion_already_satisfied', status: 409, message: 'This criterion is already closed.' };
@@ -153,7 +162,7 @@ function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind,
153
162
  return { code: 'criterion_needs_recording', status: 409, message: 'This criterion is user-facing, so it needs a UAT recording. Only a criterion marked backend-only when it was written can be signed off without one.' };
154
163
  }
155
164
  const signer = String(signerId);
156
- const isOwner = ownerId != null && String(ownerId) === signer;
165
+ const isOwner = signerIsOwner === true || (ownerId != null && String(ownerId) === signer);
157
166
  if (!isOwner && shipperIds.map(String).includes(signer)) {
158
167
  return { code: 'signer_shipped_this_work', status: 403, message: 'You shipped work linked to this criterion, so someone else has to sign it off. Any other reviewer, or the project owner, can.' };
159
168
  }
@@ -412,9 +412,9 @@ module.exports = function buildProjectSettingsRouter() {
412
412
  router.patch('/project-settings/spend-cap', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('treasury'), async (req, res) => {
413
413
  if (api.validateOrRespond(req, res, { usd: {} })) return; // a number of dollars, or null to turn it off; parsed below
414
414
  const raw = req.body ? req.body.usd : undefined;
415
- if (raw === undefined) return res.fail('bad_spend_cap', { status: 400, message: 'usd: send a number of dollars a month, or null to turn the cap off' });
415
+ if (raw === undefined) return res.fail('bad_spend_cap', { status: 400, message: 'Send a number of dollars a month, or nothing to turn the cap off.' });
416
416
  const parsed = raw === null ? { usd: null } : api.projectSettings.parseSpendCap(raw);
417
- if (parsed.reason) return res.fail('bad_spend_cap', { status: 400, message: `usd: ${parsed.reason}` });
417
+ if (parsed.reason) return res.fail('bad_spend_cap', { status: 400, message: parsed.reason });
418
418
  try {
419
419
  await api.projectSettings.setSpendCap(parsed.usd, req.builder.id);
420
420
  const resolved = await api.projectSettings.resolveSpendCap();
@@ -29,6 +29,7 @@ const api = require('../../../src/module-api');
29
29
  // depRefusalRow: the ONE shape a dependency refusal carries, shared with the claim
30
30
  // path so promote and claim describe a blocked task identically (task 1003553).
31
31
  const { depRefusalRow } = require('../dead-deps.js');
32
+ const { claimableCountsByGoal } = require('../claimable-counts');
32
33
  // gradeQualityWhere: the ONE definition of a task_grades row that is an actual
33
34
  // quality verdict rather than an outage or an errored worker (task 1002652).
34
35
  // The acclaim feed reads it so a grader outage never reaches a voter as 0.0.
@@ -207,16 +208,13 @@ module.exports = function buildTasksRouter() {
207
208
  if (fit && !VALID_FITS.has(fit)) {
208
209
  return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
209
210
  }
210
- // task 1003278 — the creator + staleness axes, so the Work Board narrows the
211
- // feed at the SOURCE instead of downloading it and reducing it in the browser.
212
- // Same validators as GET /tasks: one vocabulary, two surfaces.
211
+ // task 1003278 — creator + staleness at the SOURCE, with GET /tasks's validators.
213
212
  const claimCreatedBy = parseCreatedBy(req.query.created_by);
214
213
  if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
215
214
  const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
216
215
  if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
217
- // ?limit=N — opt-in, for a caller that renders N rows (goal 1000072; the hall's
218
- // two-line Home card was pulling this whole feed, descriptions and all). It slices
219
- // the RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
216
+ // ?limit=N — opt-in, for a caller that renders N rows (goal 1000072). It slices the
217
+ // RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
220
218
  const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
221
219
  if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
222
220
  const [feed, active] = await Promise.all([
@@ -228,8 +226,11 @@ module.exports = function buildTasksRouter() {
228
226
  createdBy: claimCreatedBy ?? null,
229
227
  staleDays: claimStaleDays ?? null,
230
228
  }),
231
- db.listActiveClaims(),
229
+ req.query.group === 'goal' ? [] : db.listActiveClaims(),
232
230
  ]);
231
+ if (req.query.group === 'goal') { // counts only, no rows (task 1004755)
232
+ return res.json({ by_goal: claimableCountsByGoal(feed), claimable_total: feed.length });
233
+ }
233
234
  const claimable = claimLimit ? feed.slice(0, claimLimit) : feed;
234
235
  // V3.R75 (#294): tick the onboarding state machine for first_start. Fire
235
236
  // and forget — the response should never wait on or fail because of the
@@ -237,9 +238,8 @@ module.exports = function buildTasksRouter() {
237
238
  // graduation broadcast. Through the `onboarding` kernel port (BV1.R81 / ADR
238
239
  // 0093 §1); ?. degrades to a no-op when the module is off.
239
240
  seams.resolveOptional('onboarding')?.safeMarkStage(pool, req.builder.id, 'first_start', req.builder);
240
- // `claimable_total` is what makes the limit honest — two rows with no count reads
241
- // as "there are only two". It is the length of the array the slice came from, never
242
- // a second count query (that is how a total and its own list drift apart, ADR 0197).
241
+ // `claimable_total` keeps the limit honest: the length of the sliced array, never a
242
+ // second count query (ADR 0197).
243
243
  res.json({ claimable, claimable_total: feed.length, active_claims: active });
244
244
  });
245
245
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.140",
3
+ "version": "1.21.142",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.140",
9
+ "version": "1.21.142",
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.21.140",
3
+ "version": "1.21.142",
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",
@@ -9842,5 +9842,25 @@
9842
9842
  "id": "1004754",
9843
9843
  "text": "Stopped a planned change that would have let anyone who signs in to a hosted project manage it. The design record now says sharing a project needs the owner to grant access on purpose; that design is filed as its own task."
9844
9844
  }
9845
+ ],
9846
+ "1.21.141": [
9847
+ {
9848
+ "id": "1004757",
9849
+ "text": "The project owner can now sign off a goal criterion even when they built some of its work, as the on-screen message always said. Everyone else who built the work still needs someone else to sign."
9850
+ },
9851
+ {
9852
+ "id": "1004662",
9853
+ "text": "The Studio now shows where every page you have submitted actually is: waiting to be applied, being applied, waiting for your review, or approved and going live, with how long it has sat there and a \"stuck\" mark when it has sat"
9854
+ },
9855
+ {
9856
+ "id": "1004755",
9857
+ "text": "Your home page now lists only the goals you have joined that are still open, and under each one says how many tasks you can pick up. Finished goals no longer crowd them out."
9858
+ }
9859
+ ],
9860
+ "1.21.142": [
9861
+ {
9862
+ "id": "1004724",
9863
+ "text": "Twelve small government fixes. Spend-cap mistakes now read as plain sentences. Removing a Lead from a building with none says what to do next. A toast names a setting by its name, not its code. Inside the Treasury, the dry-pot"
9864
+ }
9845
9865
  ]
9846
9866
  }
@@ -358,11 +358,12 @@ function describeTweakAutopickup(resolved) {
358
358
  // it is said so and a new loan is recommended against. It never stops work.
359
359
  const MAX_SPEND_CAP_USD = 1000000;
360
360
  function parseSpendCap(raw) {
361
- if (raw == null || raw === '') return { reason: 'empty' };
361
+ // The reasons are plain sentences a person reads as-is (task 1004724).
362
+ if (raw == null || raw === '') return { reason: 'Type a number of dollars a month, or clear the cap to turn it off.' };
362
363
  const n = Number(raw);
363
- if (!Number.isFinite(n)) return { reason: 'not a number' };
364
- if (n <= 0) return { reason: 'must be more than $0; clear the cap to turn it off' };
365
- if (n > MAX_SPEND_CAP_USD) return { reason: `out of range (1..${MAX_SPEND_CAP_USD})` };
364
+ if (!Number.isFinite(n)) return { reason: 'That is not a number. Type a number of dollars a month, like 200.' };
365
+ if (n <= 0) return { reason: 'The cap must be more than $0. To have no cap, clear it instead.' };
366
+ if (n > MAX_SPEND_CAP_USD) return { reason: `The cap can be at most $${MAX_SPEND_CAP_USD.toLocaleString('en-US')} a month.` };
366
367
  return { usd: Math.round(n * 100) / 100 };
367
368
  }
368
369
  // resolveSpendCap() — { on, usd, source, invalid }. Off (usd null) unless a usable
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.140'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.142'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -238,6 +238,48 @@ await test('SIGNOFF: a shipper is refused and NOTHING is written', async () => {
238
238
  assert.ok(!db.calls.some((c) => /WITH candidate AS/.test(c.sql)), 'a refusal never runs the close');
239
239
  });
240
240
 
241
+ // task 1004757: the owner the refusal names is the government's owner seat. The
242
+ // hub's owner was refused because only the founding builder (lowest-id human)
243
+ // counted, and on the hub that is someone else.
244
+ const ownerSeat = (ids) => ({ positionsFor: async (id) => ({ held: ids.includes(String(id)) ? [{ kind: 'owner' }] : [{ kind: 'board' }] }) });
245
+
246
+ await test('LADDER: the owner seat may sign off work they shipped, even when not the founding builder', () => {
247
+ assert.equal(code({ signerId: 7, ownerId: 3, signerIsOwner: true }), null);
248
+ assert.equal(code({ signerId: 7, ownerId: 3, signerIsOwner: false }), 'signer_shipped_this_work');
249
+ });
250
+
251
+ await test('SIGNOFF: a shipper holding the owner seat is allowed and the row is written', async () => {
252
+ const db = signoffDb({ linked: SHIPPED });
253
+ const out = await uatDb.signOff(
254
+ { criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
255
+ { pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: ownerSeat(['7']) },
256
+ );
257
+ assert.equal(out.ok, true, JSON.stringify(out.refusal));
258
+ assert.equal(inserts(db).length, 1);
259
+ });
260
+
261
+ await test('SIGNOFF: a shipper on the board but NOT the owner seat is still refused', async () => {
262
+ const db = signoffDb({ linked: SHIPPED });
263
+ const out = await uatDb.signOff(
264
+ { criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
265
+ { pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: ownerSeat(['9']) },
266
+ );
267
+ assert.equal(out.ok, false);
268
+ assert.equal(out.refusal.code, 'signer_shipped_this_work');
269
+ assert.equal(inserts(db).length, 0);
270
+ });
271
+
272
+ await test('SIGNOFF: an unreadable owner seat falls back to the founding builder, never wider', async () => {
273
+ const broken = { positionsFor: async () => { throw new Error('down'); } };
274
+ const db = signoffDb({ linked: SHIPPED });
275
+ const out = await uatDb.signOff(
276
+ { criterionId: 5, kind: 'uat', signerId: 7, recording: REC, liveAttested: true },
277
+ { pool: db, withTx: withTx(db), deployPort: null, ownerId: 3, government: broken },
278
+ );
279
+ assert.equal(out.ok, false);
280
+ assert.equal(out.refusal.code, 'signer_shipped_this_work');
281
+ });
282
+
241
283
  await test('SIGNOFF: the project owner may sign off their own shipped work', async () => {
242
284
  const db = signoffDb({ linked: [{ id: 11, status: 'shipped', shipped_by: 3, shipped_at: new Date() }] });
243
285
  const out = await uatDb.signOff(