@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.
- package/.bongos-core.json +69 -49
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
- package/docs/api/openapi.json +3 -4
- package/docs/copy-inventory.md +107 -104
- package/docs/copy-registry.json +156 -129
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +417 -417
- package/modules/copy-desk/page-status.js +51 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
- package/modules/government/db.js +6 -0
- package/modules/government/decision-board.js +2 -1
- package/modules/hall-ui/public/city-draw.js +4 -1
- package/modules/hall-ui/public/city.css +1 -1
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/goals.js +31 -8
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +35 -10
- package/modules/hall-ui/public/my-agents.states.json +1 -1
- package/modules/hall-ui/public/settings-interaction.js +7 -2
- package/modules/hall-ui/public/settings-law-locks.js +53 -0
- package/modules/hall-ui/public/settings.html +3 -2
- package/modules/hall-ui/public/settings.js +9 -4
- package/modules/hall-ui/public/street-deck.css +4 -0
- package/modules/hall-ui/public/street-office.js +73 -1
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +4 -1
- package/modules/hall-ui/public/studio-lib.js +21 -1
- package/modules/hall-ui/public/studio.css +11 -0
- package/modules/hall-ui/public/studio.js +8 -1
- package/modules/hall-ui/records/living-city.md +3 -1
- package/modules/ideas/governor-docket.js +4 -1
- package/modules/lifecycle/claimable-counts.js +19 -0
- package/modules/lifecycle/criterion-uat-db.js +18 -1
- package/modules/lifecycle/criterion-uat.js +12 -3
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/modules/lifecycle/routes/tasks.js +10 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/project-settings.js +5 -4
- package/src/module-api.js +1 -1
- package/tests/criterion_uat.mjs +42 -0
- package/tests/government_tidy_up.mjs +223 -0
- package/tests/hall_goals_open_first.mjs +120 -0
- 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-
|
|
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-
|
|
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-
|
|
246
|
-
<script src="/builders/street.js?v=2026-10-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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:
|
|
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 —
|
|
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
|
|
218
|
-
//
|
|
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`
|
|
241
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
365
|
-
if (n > MAX_SPEND_CAP_USD) return { reason: `
|
|
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.
|
|
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');
|
package/tests/criterion_uat.mjs
CHANGED
|
@@ -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(
|