@bongos/core 1.21.141 → 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 (41) hide show
  1. package/.bongos-core.json +55 -45
  2. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
  3. package/docs/copy-inventory.md +97 -96
  4. package/docs/copy-registry.json +118 -109
  5. package/docs/module-api-changelog.md +3 -1
  6. package/docs/page-inventory.json +1 -0
  7. package/docs/page-readings.json +392 -392
  8. package/modules/copy-desk/page-status.js +51 -2
  9. package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
  10. package/modules/government/db.js +6 -0
  11. package/modules/government/decision-board.js +2 -1
  12. package/modules/hall-ui/public/city-draw.js +4 -1
  13. package/modules/hall-ui/public/city.css +1 -1
  14. package/modules/hall-ui/public/city.html +3 -3
  15. package/modules/hall-ui/public/my-agents.html +1 -1
  16. package/modules/hall-ui/public/my-agents.js +35 -10
  17. package/modules/hall-ui/public/my-agents.states.json +1 -1
  18. package/modules/hall-ui/public/settings-interaction.js +7 -2
  19. package/modules/hall-ui/public/settings-law-locks.js +53 -0
  20. package/modules/hall-ui/public/settings.html +3 -2
  21. package/modules/hall-ui/public/settings.js +9 -4
  22. package/modules/hall-ui/public/street-deck.css +4 -0
  23. package/modules/hall-ui/public/street-office.js +73 -1
  24. package/modules/hall-ui/public/street.html +4 -4
  25. package/modules/hall-ui/public/street.js +4 -1
  26. package/modules/hall-ui/public/studio-lib.js +21 -1
  27. package/modules/hall-ui/public/studio.css +11 -0
  28. package/modules/hall-ui/public/studio.js +8 -1
  29. package/modules/hall-ui/records/living-city.md +3 -1
  30. package/modules/ideas/governor-docket.js +4 -1
  31. package/modules/lifecycle/criterion-uat-db.js +18 -1
  32. package/modules/lifecycle/criterion-uat.js +12 -3
  33. package/modules/lifecycle/routes/project-settings.js +2 -2
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +14 -0
  37. package/src/bongos/project-settings.js +5 -4
  38. package/src/module-api.js +1 -1
  39. package/tests/criterion_uat.mjs +42 -0
  40. package/tests/government_tidy_up.mjs +223 -0
  41. package/tests/hall_studio_home.mjs +20 -0
@@ -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
@@ -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();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.141",
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.141",
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.141",
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",
@@ -9844,9 +9844,23 @@
9844
9844
  }
9845
9845
  ],
9846
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
+ },
9847
9855
  {
9848
9856
  "id": "1004755",
9849
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."
9850
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
+ }
9851
9865
  ]
9852
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.141'; // 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(
@@ -0,0 +1,223 @@
1
+ // tests/government_tidy_up.mjs — the government tidy-up after the goal 1000111
2
+ // chain (task 1004724). One case per item that changed behaviour; DB-free (every
3
+ // read is stubbed at the module seam it goes through).
4
+ //
5
+ // 1 the spend-cap refusals are plain sentences, sent as-is
6
+ // 2 "no Lead to remove" says what to do next
7
+ // 3 a setting a law already holds is named by its row name, not its key
8
+ // 5 /settings shows the law locks its reads report (settings-law-locks.js)
9
+ // 7 in every city column the taller building stands at the back
10
+ // 8 the city plaque's words are in the body face in both modes
11
+ // 9 the docket reads every open blocker, so its counts are complete
12
+ // 10 the office offers "who should lead", filed as the appoint_lead People matter
13
+ // 11 a habit already up for a vote reads "proposed" after a reload
14
+ // 12 two appointments racing to one unseated building: a clean refusal
15
+ // 4 the treasury's dry-pot ask shows once: the book's card steps aside for the desk row
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { test } from 'node:test';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
27
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
28
+
29
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
30
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
31
+ const projectSettings = require(path.join(ROOT, 'src', 'bongos', 'project-settings.js'));
32
+ const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
33
+ const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
34
+ const decisionBoard = require(path.join(ROOT, 'modules', 'government', 'decision-board.js'));
35
+ const ideasBlockers = require(path.join(ROOT, 'modules', 'ideas', 'blockers.js'));
36
+ const ideasDocket = require(path.join(ROOT, 'modules', 'ideas', 'governor-docket.js'));
37
+ const City = require(path.join(HALL, 'city-draw.js'));
38
+ const MyAgents = require(path.join(HALL, 'my-agents.js'));
39
+ const Laws = require(path.join(HALL, 'settings-law-locks.js'));
40
+ const Office = require(path.join(HALL, 'street-office.js'));
41
+
42
+ async function withStubs(target, stubs, fn) {
43
+ const originals = {};
44
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = target[k]; target[k] = v; }
45
+ try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) target[k] = v; }
46
+ }
47
+
48
+ test('1: every spend-cap refusal is a plain sentence, and the route sends it as-is', () => {
49
+ for (const raw of ['', 'lots', 0, -5, projectSettings.MAX_SPEND_CAP_USD + 1]) {
50
+ const { reason } = projectSettings.parseSpendCap(raw);
51
+ assert.ok(reason, 'refused: ' + raw);
52
+ assert.match(reason, /^[A-Z].*\.$/, 'a sentence: ' + reason);
53
+ assert.doesNotMatch(reason, /usd|\.\.|^empty$|out of range/i, 'no code words: ' + reason);
54
+ }
55
+ assert.equal(projectSettings.parseSpendCap(200).usd, 200);
56
+ const route = read('modules', 'lifecycle', 'routes', 'project-settings.js');
57
+ assert.match(route, /res\.fail\('bad_spend_cap', \{ status: 400, message: parsed\.reason \}\)/);
58
+ assert.doesNotMatch(route, /message: `usd: /);
59
+ });
60
+
61
+ test('2: removing a Lead from a building with none says what to do next', async () => {
62
+ await withStubs(govDb, {
63
+ listDecisionRules: async () => [], listLeads: async () => [],
64
+ createMatter: async () => { throw new Error('nothing is filed'); },
65
+ openBoardItem: async () => { throw new Error('nothing is opened'); },
66
+ }, async () => {
67
+ const out = await board.openMatter({ area: 'people', title: 'No Lead', action: { type: 'remove_lead', building: 'review' }, proposedBy: '42' });
68
+ assert.equal(out.reason, 'no_lead');
69
+ assert.match(out.what_this_means, /has no Lead to remove/);
70
+ assert.match(out.what_this_means, /propose a Lead/, 'the next step is named');
71
+ });
72
+ });
73
+
74
+ test('3: a setting a law already holds is named by its row name in the toast', () => {
75
+ const src = read('modules', 'hall-ui', 'public', 'my-agents.js');
76
+ assert.match(src, /nameFor\(str\(err\.details\.setting\)\)/);
77
+ assert.doesNotMatch(src, /already holds ' \+ str\(err\.details/, 'never the raw key');
78
+ assert.equal(MyAgents.SETTING.verbosity.name, 'Reply length');
79
+ });
80
+
81
+ test('5: a law lock on /settings disables what it rules out and names the law', () => {
82
+ const data = { law_locks: [
83
+ { setting: 'verbosity', value: 'concise', law_no: 2, law_text: 'Agents keep replies short', enforced: true, allowed: ['concise'] },
84
+ { setting: 'wandering', max: 'focused', law_no: 3, law_text: 'Agents stay on task', enforced: true, allowed: ['locked', 'focused'] },
85
+ { setting: 'machinery', value: 'nonsense', law_no: 4, enforced: false, reason: 'value_not_allowed' },
86
+ ] };
87
+ const fixed = Laws.lockFor(data, 'verbosity');
88
+ assert.equal(Laws.allows(fixed, 'concise'), true);
89
+ assert.equal(Laws.allows(fixed, 'detailed'), false);
90
+ assert.equal(Laws.lawLine(fixed), 'Law 2 sets this for every agent in the project: "Agents keep replies short". Only a vote can change it.');
91
+ const cap = Laws.lockFor(data, 'wandering');
92
+ assert.deepEqual(['locked', 'focused', 'balanced', 'exploratory'].map((v) => Laws.allows(cap, v)), [true, true, false, false]);
93
+ assert.match(Laws.lawLine(cap), /^Law 3 caps this/);
94
+ assert.equal(Laws.tagFor(cap), 'set by law 3');
95
+ assert.equal(Laws.lockFor(data, 'machinery'), null, 'a lock that enforces nothing locks nothing');
96
+ assert.equal(Laws.lockFor({}, 'verbosity'), null);
97
+ assert.equal(Laws.allows(null, 'anything'), true);
98
+ // both panels read it, and the page loads it before either
99
+ assert.match(read('modules', 'hall-ui', 'public', 'settings-interaction.js'), /Laws\.lockFor\(data, f\.field\)/);
100
+ assert.match(read('modules', 'hall-ui', 'public', 'settings.js'), /Laws\.lockFor\(data, 'wandering'\)/);
101
+ const html = read('modules', 'hall-ui', 'public', 'settings.html');
102
+ const at = (s) => html.indexOf(s);
103
+ assert.ok(at('settings-law-locks.js') > 0 && at('settings-law-locks.js') < at('/builders/settings.js') && at('settings-law-locks.js') < at('settings-interaction.js'));
104
+ });
105
+
106
+ test('7: in every screen column of the civic quarter the taller building stands at the back', () => {
107
+ const at = City.CIVIC_SLOTS.map(([key, x, y]) => ({ key, col: x - y, depth: x + y, roof: City.ROOF[key] }));
108
+ for (const b of at) {
109
+ for (const o of at.filter((z) => z.col === b.col && z.depth > b.depth)) {
110
+ assert.ok(o.roof < b.roof, o.key + ' stands in front of the shorter ' + b.key);
111
+ }
112
+ }
113
+ assert.equal(new Set(City.CIVIC_SLOTS.map((s) => s[0])).size, 9, 'nine buildings');
114
+ assert.equal(new Set(City.CIVIC_SLOTS.map((s) => s[1] + ',' + s[2])).size, 9, 'nine lots');
115
+ });
116
+
117
+ test('8: the plaque words are in the body face (the light hall maps labels to mono)', () => {
118
+ const css = read('modules', 'hall-ui', 'public', 'city.css');
119
+ assert.match(css, /\.city-plaque__words \{ font-family: var\(--font-body\);/);
120
+ });
121
+
122
+ test('9: the docket reads every open blocker, so its counts are complete past 50', async () => {
123
+ const many = Array.from({ length: 120 }, (_, i) => ({ id: String(i + 1), title: 'Blocker ' + (i + 1), body_md: '', status: 'open', created_at: '2026-10-01T00:00:00.000Z', blocking_task_ids: [] }));
124
+ let asked = null;
125
+ seams._reset();
126
+ try {
127
+ await withStubs(ideasBlockers, { listOpenBlockers: async (o) => { asked = o; return many.slice(0, o && o.limit != null ? o.limit : many.length); } }, async () => {
128
+ ideasDocket.contributeIdeasToDocket();
129
+ const c = seams.contributions('governor.docket').find((x) => x.source === 'blockers');
130
+ const rows = await c.read({ has: () => true });
131
+ assert.equal(rows.length, 120, 'all of them');
132
+ assert.ok(asked.limit == null || asked.limit >= 1000, 'a ceiling far past any real list');
133
+ });
134
+ } finally { seams._reset(); }
135
+ });
136
+
137
+ test('10: the office offers who should lead to a builder who may open a board item, filed as appoint_lead', () => {
138
+ const living = { seats_real: true, owner: { name: 'Builder 1' }, buildings: [
139
+ { key: 'treasury', name: 'Treasury', lead: { id: '7', name: 'Gus', since: '2026-10-01' }, structure: null, construction: [], answers: { ids: ['7'] } },
140
+ ] };
141
+ const roster = { builders: [
142
+ { id: '9', display_name: 'Zoe', status: 'active' }, { id: '7', display_name: 'Gus', status: 'active' },
143
+ { id: '3', github_login: 'ann', status: 'active' }, { id: '4', display_name: 'Old', status: 'inactive' },
144
+ ] };
145
+ const may = { permissions: { 'board.item.open': true } };
146
+ assert.equal(Office.appointPlan(living, 'treasury', roster, { permissions: {} }), null, 'not offered without the perm');
147
+ assert.equal(Office.appointPlan(living, 'treasury', roster, null), null);
148
+ const plan = Office.appointPlan(living, 'treasury', roster, may);
149
+ assert.deepEqual(plan.people.map((p) => p.name), ['ann', 'Zoe'], 'active people, A to Z, never the Lead in office');
150
+ assert.equal(plan.seated, true);
151
+ const body = Office.appointBody(plan, '9');
152
+ assert.deepEqual(body, { area: 'people', title: 'Appoint Zoe as Lead of Treasury', action: { type: 'appoint_lead', building: 'treasury', builder_id: 9 } });
153
+ assert.equal(decisionBoard.validateMatterAction('people', body.action).ok, true, 'the server\'s own shape');
154
+ assert.equal(Office.appointBody(plan, '7'), null, 'only someone on the list');
155
+ assert.equal(Office.appointPlan(living, 'treasury', roster, may, { leadFiled: { treasury: true } }).filed, true);
156
+ });
157
+
158
+ test('11: a habit already up for a vote reads proposed after a reload', () => {
159
+ const room = { open: [
160
+ { id: '55', subject_type: 'constitutional_amendment', subject: { kind: 'laws', proposed: { enact: [{ text: 'Agents reply in detail', locks: [{ setting: 'verbosity', value: 'detailed' }] }] } } },
161
+ { id: '56', subject_type: 'constitutional_amendment', subject: { kind: 'board', proposed: { enact: [{ locks: [{ setting: 'machinery', value: 'quiet' }] }] } } },
162
+ ] };
163
+ const pending = MyAgents.pendingLaws(room);
164
+ assert.deepEqual(pending, { 'verbosity=detailed': '55' }, 'only laws sittings');
165
+ assert.deepEqual(MyAgents.pendingLaws(null), {});
166
+ assert.deepEqual(MyAgents.pendingLaws(false), {}, 'a 403 reads as none');
167
+ const me = {
168
+ builder: { id: '1' }, permissions: { 'board.item.open': true },
169
+ interaction: { fields: [{ field: 'verbosity', default_value: 'concise' }, { field: 'machinery', default_value: 'show' }], stored: { verbosity: 'detailed', machinery: 'quiet' }, effective: {} },
170
+ };
171
+ const v = MyAgents.buildView({ me, pending, proposed: {} }, {});
172
+ const h = (k) => v.habits.find((x) => x.key === k);
173
+ assert.equal(h('verbosity').proposed, true);
174
+ assert.equal(h('verbosity').item, '55');
175
+ assert.equal(h('machinery').proposed, false);
176
+ // a vote for ANOTHER value of the same setting is not this habit
177
+ const other = MyAgents.buildView({ me, pending: { 'verbosity=concise': '57' }, proposed: {} }, {});
178
+ assert.equal(other.habits.find((x) => x.key === 'verbosity').proposed, false);
179
+ });
180
+
181
+ test('12: two appointments racing to an unseated building end in a clean refusal, not a DB error', async () => {
182
+ const fake = (insertError) => ({
183
+ query: async (sql) => {
184
+ if (/^\s*INSERT INTO government_leads/.test(sql)) throw insertError;
185
+ if (/^\s*SELECT/.test(sql)) return { rows: [] };
186
+ return { rows: [] };
187
+ },
188
+ release: () => {},
189
+ });
190
+ const unique = Object.assign(new Error('duplicate key value violates unique constraint "government_leads_in_office_uniq"'), { code: '23505', constraint: 'government_leads_in_office_uniq' });
191
+ await withStubs(api.pool, { connect: async () => fake(unique) }, async () => {
192
+ const out = await govDb.applyLeadAct({ itemId: '40', action: { type: 'appoint_lead', building: 'planning', builder_id: 45, charter: 'x' } });
193
+ assert.equal(out.done, false);
194
+ assert.equal(out.reason, 'lead_seated_meanwhile');
195
+ assert.doesNotMatch(out.what_this_means, /duplicate|constraint|23505/);
196
+ });
197
+ const other = Object.assign(new Error('boom'), { code: '23503' });
198
+ await withStubs(api.pool, { connect: async () => fake(other) }, async () => {
199
+ await assert.rejects(govDb.applyLeadAct({ itemId: '40', action: { type: 'appoint_lead', building: 'planning', builder_id: 45, charter: 'x' } }), /boom/, 'any other failure still throws');
200
+ });
201
+ });
202
+
203
+ test('4: a book card steps aside when the floor desk below holds the same matter', () => {
204
+ const node = (attrs, kids = []) => {
205
+ const n = { attrs, kids, parentNode: null, getAttribute: (k) => (k in attrs ? attrs[k] : null) };
206
+ kids.forEach((k) => { k.parentNode = n; });
207
+ n.removeChild = (c) => { n.kids = n.kids.filter((x) => x !== c); c.parentNode = null; };
208
+ return n;
209
+ };
210
+ const all = (n) => n.kids.flatMap((k) => [k, ...all(k)]);
211
+ const card1 = node({ 'data-ask': 'treasury_spending:1' });
212
+ const card2 = node({ 'data-ask': 'blockers:9' });
213
+ const book = node({}, [card1, card2]);
214
+ const govt = node({}, [book]);
215
+ govt.querySelectorAll = (sel) => (sel === '[data-ask]' ? all(govt).filter((n) => n.attrs['data-ask']) : []);
216
+ const desk = node({}, [node({ 'data-row': 'treasury_spending:1' })]);
217
+ desk.querySelector = (sel) => { const m = /^\[data-row="(.*)"\]$/.exec(sel); return m ? all(desk).find((n) => n.attrs['data-row'] === m[1]) || null : null; };
218
+ assert.equal(Office.page.settle(govt, desk), 1);
219
+ assert.deepEqual(book.kids.map((k) => k.attrs['data-ask']), ['blockers:9'], 'the one the desk shows is gone; the other stays');
220
+ assert.equal(Office.page.settle(null, desk), 0);
221
+ // street.js settles the book after it fills the floor desk
222
+ assert.match(read('modules', 'hall-ui', 'public', 'street.js'), /fillDesk\(on, bv\); if \(OP && OP\.settle\) OP\.settle\(\$\('insideGovt'\), on\);/);
223
+ });
@@ -248,6 +248,23 @@ try {
248
248
  assert.equal(lib.askedLine(0), '');
249
249
  });
250
250
 
251
+ await test('Where your pages are (task 1004662): the server in-flight list, worded, stuck first, with how long', () => {
252
+ assert.deepEqual(lib.inFlightRows(PAGES, NOW).map((r) => [r.page_id, r.state, r.age, r.stuck]), [
253
+ ['landing:index', 'Being written', '', false],
254
+ ], 'the open round the ledger holds, no date on it so no age');
255
+ const rows = lib.inFlightRows({ in_flight: [
256
+ { page_id: 'landing:contact', title: 'Contact', surface: 'landing', state_word: 'Approved, going live', since: day(17), stuck: true },
257
+ { page_id: 'builders:work', title: 'Tasks', surface: 'builders', state_word: 'Submitted, waiting to be applied', since: day(1), stuck: false },
258
+ { page_id: 'status:index', title: 'Status', surface: 'status', state_word: null, since: NOW.toISOString(), stuck: true },
259
+ ] }, NOW);
260
+ assert.deepEqual(rows.map((r) => [r.title, r.state, r.age, r.stuck]), [
261
+ ['Contact', 'Approved, going live', 'for 17 days', true],
262
+ ['Tasks', 'Submitted, waiting to be applied', 'for 1 day', false],
263
+ ['Status', 'Needs a look', 'since today', true],
264
+ ]);
265
+ assert.deepEqual(lib.inFlightRows(null, NOW), []);
266
+ });
267
+
251
268
  await test('a word on the page finds its own line: the most specific wins, holes alone never match, shared never', () => {
252
269
  const sentence = 'Home, on the landing page, is where you left it. Twelve of its thirty-eight lines are yours so far.';
253
270
  assert.equal(lib.matchLine(sentence, STUDIO_READING.lines).key, 'L0003', 'not the generic "{…} of {…}"');
@@ -335,6 +352,9 @@ try {
335
352
  assert.match(body, /1 of 2 tweaked/);
336
353
  assert.match(body, /style="width: 50%"/);
337
354
  assert.match(body, /1 page has been asked for/);
355
+ assert.match(body, /Where your pages are/);
356
+ assert.match(body, /st-flight__state">Being written</, 'the open round shows where it is');
357
+ assert.match(body, /href="\/builders\/tweak-editor\?page=landing:index"/);
338
358
  await s.clickIn('st-panel', '[data-close-panel]');
339
359
  assert.equal(s.el('st-panel').hidden, true);
340
360
  assert.ok(!s.html.classList.contains('st-open'));