@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
@@ -271,9 +271,57 @@ function orderedSurfaces(inventory) {
271
271
  return ids.map((id, i) => ({ id, i })).sort((a, b) => rank(a.id) - rank(b.id) || a.i - b.i).map((x) => x.id);
272
272
  }
273
273
 
274
+ // ---------------------------------------------------------------------------
275
+ // Pages in flight (task 1004662). The status label counts SHIPPED rounds only,
276
+ // so a page submitted weeks ago still read "Untweaked" and the Approval queue
277
+ // (applied rounds only) stayed empty: the artist's finished work looked lost.
278
+ // This is every page with an open round, where it is, since when (the round's
279
+ // last update), and whether it has sat there too long. Waiting for review is
280
+ // the artist's own move and a draft is theirs to finish, so neither is stuck;
281
+ // a state the reads cannot name always needs a look.
282
+ // ---------------------------------------------------------------------------
283
+
284
+ const DAY_MS = 24 * 60 * 60 * 1000;
285
+ const IN_FLIGHT = Object.freeze({
286
+ draft: { word: 'Being written', stuck_after: null },
287
+ writing: { word: 'Being written', stuck_after: null },
288
+ submitted: { word: 'Submitted, waiting to be applied', stuck_after: 3 * DAY_MS },
289
+ applying: { word: 'Being applied', stuck_after: DAY_MS },
290
+ waiting_for_artist: { word: 'Waiting for your review', stuck_after: null },
291
+ landing: { word: 'Approved, going live', stuck_after: DAY_MS },
292
+ unknown: { word: 'Needs a look', stuck_after: 0 },
293
+ });
294
+ const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'writing', 'draft'];
295
+
296
+ function inFlightFor(list, byPage, nowMs) {
297
+ const out = [];
298
+ for (const p of list) {
299
+ const o = p.open_round;
300
+ if (!o) continue;
301
+ const rounds = byPage.get(p.id) || [];
302
+ const r = rounds.find((x) => String(x.id) === o.task_id) || {};
303
+ const kind = IN_FLIGHT[o.state] || IN_FLIGHT.unknown;
304
+ const since = iso(r.updated_at || r.created_at);
305
+ const age = since ? nowMs - Date.parse(since) : null;
306
+ out.push({
307
+ page_id: p.id,
308
+ title: p.title,
309
+ surface: p.surface,
310
+ task_id: o.task_id,
311
+ state: o.state,
312
+ state_word: kind.word,
313
+ since,
314
+ stuck: kind.stuck_after != null && (kind.stuck_after === 0 || (age != null && age > kind.stuck_after)),
315
+ holder: o.holder,
316
+ });
317
+ }
318
+ const rank = (s) => { const i = IN_FLIGHT_ORDER.indexOf(s); return i === -1 ? 0 : i; };
319
+ return out.sort((a, b) => (b.stuck - a.stuck) || rank(a.state) - rank(b.state) || String(a.since || '').localeCompare(String(b.since || '')));
320
+ }
321
+
274
322
  // GET /copy-desk/pages — every page's status, count and drift, plus the
275
- // per-surface rollup (Artist Review Status).
276
- function composePages({ inventory, readings, tasks }) {
323
+ // per-surface rollup (Artist Review Status) and the pages in flight.
324
+ function composePages({ inventory, readings, tasks, now = Date.now() }) {
277
325
  const byPage = indexRounds(tasks);
278
326
  const readingsOk = !!readings;
279
327
  const rIdx = readingIndex(readings);
@@ -289,6 +337,7 @@ function composePages({ inventory, readings, tasks }) {
289
337
  pages: list,
290
338
  surfaces,
291
339
  totals: { tweaked, total: list.length, line: `${tweaked} of ${list.length} pages tweaked` },
340
+ in_flight: inFlightFor(list, byPage, now instanceof Date ? now.getTime() : Number(now)),
292
341
  };
293
342
  }
294
343
 
@@ -236,5 +236,42 @@ await test('no payout rows yet (TW13 unbuilt) reads 0 credits, while pages tweak
236
236
  assert.deepEqual([carol.credits, carol.this_week, carol.pages_tweaked], [0, 0, 0], 'an abandoned round is not a page tweaked');
237
237
  });
238
238
 
239
+ // Pages in flight (task 1004662): the live case. Submitted rounds never
240
+ // applied and approved rounds stuck before landing read "Untweaked" and never
241
+ // reach the Approval queue, so the list is the only place they show.
242
+ await test('in_flight: every open round, its plain state, how long, and stuck when it has sat too long', () => {
243
+ const NOW = new Date('2026-10-16T12:00:00.000Z');
244
+ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
245
+ const pg = (id) => ({ id, surface: id.split(':')[0], title: id, path: '/', files: [] });
246
+ const ids = ['landing:a', 'landing:b', 'landing:c', 'builders:d', 'builders:e', 'builders:f', 'builders:g', 'status:h'];
247
+ const inventory = { surfaces: [{ id: 'landing' }, { id: 'builders' }, { id: 'status' }], pages: ids.map(pg) };
248
+ const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
249
+ const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), ...extra });
250
+ const tasks = [
251
+ round(1, 'landing:a', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:a'), 1)}\n\n${batch('landing:a')}`, updated_at: ago(9) }),
252
+ round(2, 'landing:b', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:b'), 1)}\n\n${batch('landing:b')}`, updated_at: ago(1) }),
253
+ round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
254
+ round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
255
+ round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(30) }),
256
+ round(6, 'builders:f', { status: 'ready', updated_at: ago(0.2) }),
257
+ round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
258
+ round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
259
+ ];
260
+ const out = status.composePages({ inventory, readings: null, tasks, now: NOW });
261
+ assert.deepEqual(out.in_flight.map((f) => [f.page_id, f.state, f.state_word, f.stuck]), [
262
+ ['builders:f', 'unknown', 'Needs a look', true],
263
+ ['landing:c', 'landing', 'Approved, going live', true],
264
+ ['status:h', 'applying', 'Being applied', true],
265
+ ['landing:a', 'submitted', 'Submitted, waiting to be applied', true],
266
+ ['landing:b', 'submitted', 'Submitted, waiting to be applied', false],
267
+ ['builders:d', 'waiting_for_artist', 'Waiting for your review', false],
268
+ ['builders:e', 'writing', 'Being written', false],
269
+ ], 'a shipped round is not in flight; waiting for review and writing are never stuck');
270
+ assert.equal(out.in_flight[1].since, ago(17));
271
+ assert.equal(out.in_flight[1].task_id, '3');
272
+ assert.equal(out.pages.find((p) => p.id === 'landing:a').status_label, 'Untweaked', 'the status still counts only what shipped');
273
+ assert.deepEqual(status.composePages({ inventory, readings: null, tasks: [], now: NOW }).in_flight, []);
274
+ });
275
+
239
276
  console.log(`\n${passed} passed, ${failed} failed`);
240
277
  process.exit(failed ? 1 : 0);
@@ -866,6 +866,12 @@ async function applyLeadAct({ itemId, action }) {
866
866
  return { done: true, lead, ended };
867
867
  } catch (err) {
868
868
  await client.query('ROLLBACK').catch(() => {});
869
+ // Two appointments to an unseated building passed at once (task 1004724):
870
+ // both saw no Lead, and the one-Lead-in-office index refused the second.
871
+ // That is a refusal to say plainly, not a database error.
872
+ if (err && err.code === '23505' && err.constraint === 'government_leads_in_office_uniq') {
873
+ return { done: false, reason: 'lead_seated_meanwhile', what_this_means: 'Another appointment to this building landed first, so this one changed nothing. Propose again to replace that Lead.' };
874
+ }
869
875
  throw err;
870
876
  } finally {
871
877
  client.release();
@@ -394,7 +394,8 @@ async function openMatter({ area, title, detailMd = null, action = null, propose
394
394
  async function leadMatterRefusal(action) {
395
395
  const inOffice = (await db.listLeads()).find((l) => l.building === action.building) || null;
396
396
  if (action.type === 'remove_lead') {
397
- return inOffice ? null : { ok: false, reason: 'no_lead', area: 'people', building: action.building, what_this_means: `${decisionRules.areaByKey(action.building).name} has no Lead to remove.` };
397
+ // task 1004724: say what to do next, not only what is missing
398
+ return inOffice ? null : { ok: false, reason: 'no_lead', area: 'people', building: action.building, what_this_means: `${decisionRules.areaByKey(action.building).name} has no Lead to remove; the owner holds it. To give it one, propose a Lead from its office.` };
398
399
  }
399
400
  if (!(await db.getActiveBuilder(action.builder_id))) return { ok: false, reason: 'unknown_builder', builder_id: String(action.builder_id) };
400
401
  if (inOffice && String(inOffice.builder_id) === String(action.builder_id) && inOffice.charter === action.charter) {
@@ -237,7 +237,10 @@
237
237
  // Charter, the tallest, takes the back corner and rises over the quarter;
238
238
  // Planning takes the centre, where nothing in front is tall enough to hide its
239
239
  // lit floor (task 1004718: behind the Charter, Planning's floor never showed).
240
- const CIVIC_SLOTS = Object.freeze([['charter', 5, 5], ['rules', 7, 5], ['planning', 9, 5], ['people', 5, 7], ['treasury', 7, 7], ['release', 9, 7], ['disputes', 5, 9], ['automation', 7, 9], ['review', 9, 9]]);
240
+ // Task 1004724 carried the rule to every column: Rules stood behind Release and
241
+ // People behind Automation, so the taller one in front hid their lit floors; in
242
+ // each column the taller building now stands at the back.
243
+ const CIVIC_SLOTS = Object.freeze([['charter', 5, 5], ['release', 7, 5], ['planning', 9, 5], ['automation', 5, 7], ['treasury', 7, 7], ['rules', 9, 7], ['disputes', 5, 9], ['people', 7, 9], ['review', 9, 9]]);
241
244
  const ROOF = Object.freeze({ charter: 158, rules: 36, treasury: 66, planning: 51, people: 31, release: 72, disputes: 60, review: 53, automation: 86 });
242
245
 
243
246
  // The four prototype districts (route = the parcel road it feeds), then the
@@ -210,7 +210,7 @@ html[data-fullscreen="on"] .city-top { right: 184px; }
210
210
  .city-plaque[hidden] { display: none; }
211
211
  .city-plaque__tile { display: inline-flex; align-items: baseline; gap: 7px; }
212
212
  .city-plaque__figure { font-size: 22px; font-weight: 300; letter-spacing: -0.03em; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--ink); }
213
- .city-plaque__words { font-family: var(--label-face); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); }
213
+ .city-plaque__words { font-family: var(--font-body); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); }
214
214
  .city-plaque__tile[data-key="you"] .city-plaque__figure { color: var(--accent-deep); }
215
215
 
216
216
  /* the card a building opens in place, in the desk line: who, one fact, its count, two doors */
@@ -12,9 +12,9 @@
12
12
  (tests/hall_tokens.mjs: no hall page hardcodes a web-font stylesheet).
13
13
  The prototype carried its own Google Fonts link for the same two faces. -->
14
14
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
15
- <link rel="stylesheet" href="/builders/city.css?v=2026-10-13-glass1">
15
+ <link rel="stylesheet" href="/builders/city.css?v=2026-10-17-tidy">
16
16
  <!-- the ask card in the glass language, shared with the street's deck (task 1004702) -->
17
- <link rel="stylesheet" href="/builders/street-deck.css?v=2026-10-13-glass1">
17
+ <link rel="stylesheet" href="/builders/street-deck.css?v=2026-10-17-tidy">
18
18
  </head>
19
19
  <!-- THE GOVERNOR CITY, HOME (task 1004524, goal 1000125, R02). The home view
20
20
  of docs/design/mocks/governor-city/Street.dc.html (homeView and the home
@@ -109,7 +109,7 @@
109
109
  <script src="/builders/palette.js" defer></script>
110
110
  <script src="/builders/gov-living.js?v=2026-10-09-positions1"></script>
111
111
  <script src="/builders/gov-feeds.js?v=2026-10-09-living3"></script>
112
- <script src="/builders/city-draw.js?v=2026-10-09-living3"></script>
112
+ <script src="/builders/city-draw.js?v=2026-10-17-tidy"></script>
113
113
  <!-- a building's card answers its first ask in place through the deck's own
114
114
  route (task 1004702): the desk rows (street-desk.js over the street's kit)
115
115
  and the deck's card and sender (street-deck.js) -->
@@ -67,6 +67,6 @@
67
67
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
68
68
  <script src="/builders/gov-living.js?v=2026-10-10-real1"></script>
69
69
  <script src="/builders/gov-positions.js?v=2026-10-09-positions1"></script>
70
- <script src="/builders/my-agents.js?v=2026-10-10-real1"></script>
70
+ <script src="/builders/my-agents.js?v=2026-10-17-tidy"></script>
71
71
  </body>
72
72
  </html>
@@ -205,11 +205,17 @@
205
205
  // setting to your value. Offered only to a builder who may open a board item.
206
206
  const canPropose = !!(obj(me.permissions) && me.permissions[PROPOSE_ATOM] === true);
207
207
  const habits = [];
208
- const proposed = obj(o.proposed) || {};
209
- const habit = (key, value, law) => ({
210
- key, name: nameFor(key) + ' ' + wordFor(key, value), fact: proposed[key] ? 'proposed' : 'only you, for now',
211
- law, lock: { setting: key, value }, proposed: !!proposed[key], item: proposed[key] && proposed[key] !== true ? str(proposed[key]) : null,
212
- });
208
+ // filed this visit (`proposed`, by setting), or already open on the board
209
+ // (`pending`, setting=value → item, pendingLaws) — so a reload keeps it
210
+ const filed = obj(o.proposed) || {};
211
+ const pending = obj(o.pending) || {};
212
+ const habit = (key, value, law) => {
213
+ const item = filed[key] || pending[key + '=' + str(value)] || null;
214
+ return {
215
+ key, name: nameFor(key) + ' ' + wordFor(key, value), fact: item ? 'proposed' : 'only you, for now',
216
+ law, lock: { setting: key, value }, proposed: !!item, item: item && item !== true ? str(item) : null,
217
+ };
218
+ };
213
219
  const held = (key) => !!(byKey[key] && (byKey[key].locked || byKey[key].cappedBy));
214
220
  const inter = obj(me.interaction);
215
221
  if (inter) {
@@ -271,6 +277,21 @@
271
277
 
272
278
  // The laws vote a habit files (POST /government/board/laws): one law, locking the
273
279
  // setting to the proposer's value.
280
+ // The habits already up for a vote (task 1004724): every OPEN laws sitting on
281
+ // the board (GET /government/board/items), as setting=value → its item id, so a
282
+ // habit proposed before a reload still reads "up for a vote".
283
+ function pendingLaws(room) {
284
+ const out = {};
285
+ arr(obj(room) && room.open).forEach((it) => {
286
+ const s = obj(it && it.subject);
287
+ if (!s || s.kind !== 'laws' || it.closed_at) return;
288
+ arr(obj(s.proposed) && s.proposed.enact).forEach((law) => arr(obj(law) && law.locks).forEach((l) => {
289
+ if (obj(l) && l.setting != null && Object.prototype.hasOwnProperty.call(l, 'value')) out[str(l.setting) + '=' + str(l.value)] = str(it.id);
290
+ }));
291
+ });
292
+ return out;
293
+ }
294
+
274
295
  function proposalFor(h, login) {
275
296
  return {
276
297
  enact: [{ text: h.law, locks: [h.lock] }],
@@ -278,7 +299,7 @@
278
299
  };
279
300
  }
280
301
 
281
- const pure = { buildView, writeFor, proposalFor, SETTING, LAW_LINES, GROUPS };
302
+ const pure = { buildView, writeFor, proposalFor, pendingLaws, SETTING, LAW_LINES, GROUPS };
282
303
  root.OTBMyAgents = pure;
283
304
  if (typeof module !== 'undefined' && module.exports) { module.exports = pure; return; }
284
305
  if (typeof document === 'undefined' || !root.BongosClient) return;
@@ -296,7 +317,7 @@
296
317
  const LOCK = '<svg class="ma-lock__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="7" width="9" height="6.5" rx="1.5"/><path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7"/></svg>';
297
318
  const TICK = '<svg class="ma-tick" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>';
298
319
 
299
- const S = { me: null, constitution: null, presets: null, skills: null, specialities: null, preview: null, proposed: {}, switched: {}, adopted: [], skillsOpen: false, readAll: false, view: null };
320
+ const S = { me: null, constitution: null, presets: null, skills: null, specialities: null, preview: null, proposed: {}, pending: {}, switched: {}, adopted: [], skillsOpen: false, readAll: false, view: null };
300
321
 
301
322
  function asNow() {
302
323
  try { return root.sessionStorage.getItem('hall-as') === 'nobody' || /[?&]as=nobody\b/.test(root.location.search) ? 'nobody' : null; }
@@ -305,7 +326,7 @@
305
326
  function view() {
306
327
  S.view = buildView({
307
328
  me: S.me, constitution: S.constitution, presets: S.presets, skills: S.skills, specialities: S.specialities,
308
- preview: S.preview, proposed: S.proposed, switched: S.switched, adopted: S.adopted, as: asNow(), project: PROJECT, now: Date.now(),
329
+ preview: S.preview, proposed: S.proposed, pending: S.pending, switched: S.switched, adopted: S.adopted, as: asNow(), project: PROJECT, now: Date.now(),
309
330
  }, { GovLiving: root.GovLiving, GovPositions: root.GovPositions });
310
331
  return S.view;
311
332
  }
@@ -560,7 +581,9 @@
560
581
  const err = r && r.data && r.data.error;
561
582
  const code = err && (err.code || err);
562
583
  if (code === 'preset_in_force') { toast('Not filed: ' + p.name + ' is already in force.'); return; }
563
- if (code === 'setting_already_locked') { toast('Not filed: a law of the project\'s own already holds ' + str(err.details && err.details.setting || 'that setting') + '. Repeal it first.'); return; }
584
+ // the setting by its row name, never its key (task 1004724)
585
+ const held = err.details && err.details.setting ? nameFor(str(err.details.setting)).toLowerCase() : 'that setting';
586
+ if (code === 'setting_already_locked') { toast('Not filed: a law of the project\'s own already holds ' + held + '. Repeal it first.'); return; }
564
587
  toast('Not filed' + (r && r.status ? ' (' + r.status + ')' : '') + '. Nothing changed.');
565
588
  }, () => toast('Not filed: no answer.'));
566
589
  }
@@ -590,9 +613,11 @@
590
613
  return;
591
614
  }
592
615
  S.me = me;
593
- const [constitution, presets, skills, specialities] = await Promise.all([
616
+ const [constitution, presets, skills, specialities, room] = await Promise.all([
594
617
  getJSON('/government/constitution'), getJSON('/government/presets'), getJSON('/me/skill-prefs'), getJSON('/specialities'),
618
+ getJSON('/government/board/items'),
595
619
  ]);
620
+ S.pending = pendingLaws(room);
596
621
  S.presets = presets || null;
597
622
  S.constitution = constitution;
598
623
  S.skills = skills || null;
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "MY AGENTS (task 1004685, the living-city prototype) through modules/ui-design/kit/render.js. Every row is filled from GET /me and its prefs, so the floors run against the hall-preview harness, which serves those reads and records the pref writes (scripts/hall-preview/pref-writes.js):\n\n node scripts/hall-preview/server.js --port 4614 --fixture-me --rank archon\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/my-agents.states.json --base-in http://builders.localhost:4614 --base-out http://builders.localhost:4614\n\nAgainst the kit's own stub the reads 404 and the page shows its sign-in line. The 'nobody' state is the same page as a builder who holds no seat (?as=nobody, read by the prototype only).",
2
+ "_": "MY AGENTS (task 1004685, the living-city prototype) through modules/ui-design/kit/render.js. Every row is filled from GET /me and its prefs, so the floors run against the hall-preview harness, which serves those reads and records the pref writes (scripts/hall-preview/pref-writes.js):\n\n node scripts/hall-preview/server.js --port 4614 --fixture-me --rank archon\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/my-agents.states.json --base-in http://builders.localhost:4614 --base-out http://builders.localhost:4614\n\nAgainst the kit's own stub the reads 404 and the page shows its sign-in line. The 'nobody' state is the same page as a builder who holds no seat (?as=nobody, a stand-in that can only take away, gov-positions.js).",
3
3
  "page": "/my-agents",
4
4
  "surface": "hall-ui",
5
5
  "stub": { "prefix": "/builders/" },
@@ -31,6 +31,8 @@
31
31
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
32
32
 
33
33
  const toAuth = () => window.location.assign(`${API}/auth/web/start`);
34
+ // the project's laws on these fields (task 1004724): settings-law-locks.js
35
+ const Laws = window.OTBSettingsLaws || { lockFor: () => null, allows: () => true, lawLine: () => '', tagFor: () => '' };
34
36
 
35
37
  function render(data) {
36
38
  const wrap = $('#interaction-rows');
@@ -45,17 +47,19 @@
45
47
  // name — four independent single-selects, not one flat list of radios.
46
48
  wrap.innerHTML = fields.map((f) => {
47
49
  const chosenValue = eff[f.field];
50
+ const lock = Laws.lockFor(data, f.field);
48
51
  const rows = (f.values || []).map((v) => {
49
52
  const id = `interaction-${f.field}-${v.value}`;
50
53
  const chosen = v.value === chosenValue;
54
+ const barred = !Laws.allows(lock, v.value);
51
55
  // No "default" tag here, deliberately: interaction-prefs.js already ends
52
56
  // every default value's LABEL with "(default)", so adding the row tag the
53
57
  // wandering picker uses rendered "Non-technical (default) · DEFAULT".
54
58
  // One source for the word, and it is the label the API already ships.
55
59
  return (
56
- `<label class="prow prow--pick" for="${id}">` +
60
+ `<label class="prow prow--pick" for="${id}"${barred ? ' aria-disabled="true"' : ''}>` +
57
61
  `<input class="pradio" type="radio" name="interaction-${escapeHtml(f.field)}" id="${id}"` +
58
- ` value="${escapeHtml(v.value)}" data-field="${escapeHtml(f.field)}"${chosen ? ' checked' : ''}>` +
62
+ ` value="${escapeHtml(v.value)}" data-field="${escapeHtml(f.field)}"${chosen ? ' checked' : ''}${barred ? ' disabled' : ''}>` +
59
63
  '<span class="prow__main">' +
60
64
  `<span class="prow__label">${escapeHtml(v.label)}</span>` +
61
65
  '</span></label>'
@@ -65,6 +69,7 @@
65
69
  `<fieldset class="pgroup" id="interaction-field-${escapeHtml(f.field)}">` +
66
70
  `<legend class="prow__label">${escapeHtml(f.label)}</legend>` +
67
71
  `<p class="prow__desc">${escapeHtml(f.blurb || '')}</p>` +
72
+ (lock ? `<p class="prow__desc prow__law" data-law="${escapeHtml(String(lock.law_no || ''))}">${escapeHtml(Laws.lawLine(lock))}</p>` : '') +
68
73
  rows +
69
74
  '</fieldset>'
70
75
  );
@@ -0,0 +1,53 @@
1
+ // modules/hall-ui/public/settings-law-locks.js — the project's LAWS on the
2
+ // /builders/settings rows (task 1004724).
3
+ //
4
+ // A law passed by the project's government may lock a personal setting: FIX it
5
+ // to one value, or CAP it. The server enforces the lock (law-locks.js, task
6
+ // 1004707) and every settings read already reports it as `law_locks` — this file
7
+ // is only how the page SAYS so: a value the lock does not allow is a disabled
8
+ // row tagged with the law, and the setting carries one line naming the law.
9
+ // Shared by the "How Claude talks to you" panel (settings-interaction.js) and the
10
+ // wandering picker (settings.js). The page decides nothing; a write the law
11
+ // forbids is refused by the route either way (409 locked_by_law).
12
+ //
13
+ // PURE: no DOM, no fetch. tests/settings_law_locks.mjs requires it in node.
14
+
15
+ (function (root) {
16
+ 'use strict';
17
+
18
+ const str = (v) => (v == null ? '' : String(v));
19
+
20
+ // The lock a law puts on `setting`, from a settings read's `law_locks`, or null.
21
+ // Only an ENFORCED lock counts: one naming no real value locks nothing.
22
+ function lockFor(data, setting) {
23
+ const locks = data && Array.isArray(data.law_locks) ? data.law_locks : [];
24
+ return locks.find((l) => l && l.enforced === true && str(l.setting) === str(setting)) || null;
25
+ }
26
+
27
+ // May `value` still be picked under `lock`? No lock: yes.
28
+ function allows(lock, value) {
29
+ if (!lock) return true;
30
+ if (!Array.isArray(lock.allowed)) return true;
31
+ return lock.allowed.map(str).includes(str(value));
32
+ }
33
+
34
+ // The one line the setting carries: which law, and what it does to the row.
35
+ function lawLine(lock) {
36
+ if (!lock) return '';
37
+ const n = lock.law_no != null ? 'Law ' + str(lock.law_no) : 'A law';
38
+ const text = str(lock.law_text).trim();
39
+ const what = Object.prototype.hasOwnProperty.call(lock, 'value')
40
+ ? ' sets this for every agent in the project'
41
+ : ' caps this for every agent in the project';
42
+ return n + what + (text ? ': "' + text + '".' : '.') + ' Only a vote can change it.';
43
+ }
44
+
45
+ // The tag a row the lock rules out wears.
46
+ function tagFor(lock) {
47
+ return lock ? 'set by ' + (lock.law_no != null ? 'law ' + str(lock.law_no) : 'a law') : '';
48
+ }
49
+
50
+ const api = { lockFor, allows, lawLine, tagFor };
51
+ root.OTBSettingsLaws = api;
52
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
53
+ }(typeof window !== 'undefined' ? window : globalThis));
@@ -482,7 +482,8 @@
482
482
  <!-- shared record overlay (#/idea|blocker/N) — every hall page carries it; this
483
483
  page was the one gap (task 2172). -->
484
484
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
485
- <script src="/builders/settings.js?v=2026-09-26-update"></script>
485
+ <script src="/builders/settings-law-locks.js?v=2026-10-17-laws"></script>
486
+ <script src="/builders/settings.js?v=2026-10-17-laws"></script>
486
487
  <!-- task 1003341: the Active sessions panel. Its own file rather than more of
487
488
  settings.js, which sits close to the 1500-line size ratchet. -->
488
489
  <script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
@@ -490,7 +491,7 @@
490
491
  <!-- task 1002514: the interaction-profile panel ("How Claude talks to you"), on
491
492
  the same precedent and for the same reason — settings.js is against the
492
493
  1500-line ratchet, and this panel shares nothing with the rest of the page. -->
493
- <script src="/builders/settings-interaction.js?v=2026-09-15-interaction"></script>
494
+ <script src="/builders/settings-interaction.js?v=2026-10-17-laws"></script>
494
495
  <script src="/builders/settings-specialities.js?v=2026-09-17-specialities"></script>
495
496
  <!-- task 1002518: the card-richness ladder. Own file on the same precedent —
496
497
  the panel grew from one switch to a rung picker with a derived default, and
@@ -413,12 +413,17 @@
413
413
  // One pick among four: a radio row per level (panel.css `.prow--pick`), the
414
414
  // whole row its label. A level above the rank ceiling is a disabled radio
415
415
  // that says so in the label voice rather than a dead "Rank-locked" button.
416
+ // a project law may fix or cap the level too (task 1004724, settings-law-locks.js)
417
+ const Laws = window.OTBSettingsLaws || null;
418
+ const lawLock = Laws ? Laws.lockFor(data, 'wandering') : null;
416
419
  wrap.innerHTML = levels.map((lv) => {
417
420
  const chosen = lv.n === eff;
418
- const locked = !lv.allowed;
421
+ const byLaw = lv.allowed && !!lawLock && !Laws.allows(lawLock, lv.key);
422
+ const locked = !lv.allowed || byLaw;
419
423
  const id = `wander-${lv.n}`;
420
424
  const tags = (lv.n === def ? '<span class="prow__tag">default for your rank</span>' : '') +
421
- (locked ? '<span class="prow__tag">rank-locked</span>' : '');
425
+ (!lv.allowed ? '<span class="prow__tag">rank-locked</span>' : '') +
426
+ (byLaw ? `<span class="prow__tag">${escapeHtml(Laws.tagFor(lawLock))}</span>` : '');
422
427
  return (
423
428
  `<label class="prow prow--pick" for="${id}" data-level="${lv.n}"${locked ? ' aria-disabled="true"' : ''}>` +
424
429
  `<input class="pradio" type="radio" name="wandering" id="${id}" value="${lv.n}"` +
@@ -431,9 +436,9 @@
431
436
  }).join('');
432
437
  const note = $('#wandering-note');
433
438
  if (note) {
434
- note.textContent = (data && data.effective_label && data.max_key)
439
+ note.textContent = ((data && data.effective_label && data.max_key)
435
440
  ? `Now: ${data.effective_label}. Your rank allows up to ${data.max_key}.`
436
- : '';
441
+ : '') + (lawLock ? ' ' + Laws.lawLine(lawLock) : '');
437
442
  }
438
443
  }
439
444
 
@@ -248,6 +248,10 @@ body[data-page="street"] .street-card { background: var(--gov-glass); border: 1p
248
248
  .office .desk-name__f { white-space: nowrap; }
249
249
  .govx-capsule { height: auto; min-height: 44px; padding: 6px 14px; display: inline-flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 1px; }
250
250
  .govx-capsule__how { font-size: 11px; font-weight: 400; color: var(--ink-faint); letter-spacing: 0.04em; }
251
+ /* who should lead (task 1004724): one pick, one chip */
252
+ .govx-appoint__who { min-height: 44px; max-width: 100%; min-width: 0; padding: 0 12px; border-radius: 999px; border: 1px solid var(--street-rule-strong); background: var(--bg); color: var(--ink); font: inherit; font-size: 14px; }
253
+ .govx-appoint__who:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
254
+ .govx-appoint__f { font-size: 13px; color: var(--ink-soft); }
251
255
  .govx-pic { margin: 0; display: flex; flex-direction: column; gap: 4px; padding: 6px 0 2px; border-top: 1px solid var(--street-rule); border-bottom: 1px solid var(--street-rule); }
252
256
  .govx-pic svg { display: block; max-width: 100%; }
253
257
  .govx-pic__cap { font-size: 12px; color: var(--ink-faint); }
@@ -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) {