@bongos/core 1.21.140 → 1.21.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.bongos-core.json +69 -49
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
  4. package/docs/api/openapi.json +3 -4
  5. package/docs/copy-inventory.md +107 -104
  6. package/docs/copy-registry.json +156 -129
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-inventory.json +1 -0
  9. package/docs/page-readings.json +417 -417
  10. package/modules/copy-desk/page-status.js +51 -2
  11. package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
  12. package/modules/government/db.js +6 -0
  13. package/modules/government/decision-board.js +2 -1
  14. package/modules/hall-ui/public/city-draw.js +4 -1
  15. package/modules/hall-ui/public/city.css +1 -1
  16. package/modules/hall-ui/public/city.html +3 -3
  17. package/modules/hall-ui/public/goals.js +31 -8
  18. package/modules/hall-ui/public/my-agents.html +1 -1
  19. package/modules/hall-ui/public/my-agents.js +35 -10
  20. package/modules/hall-ui/public/my-agents.states.json +1 -1
  21. package/modules/hall-ui/public/settings-interaction.js +7 -2
  22. package/modules/hall-ui/public/settings-law-locks.js +53 -0
  23. package/modules/hall-ui/public/settings.html +3 -2
  24. package/modules/hall-ui/public/settings.js +9 -4
  25. package/modules/hall-ui/public/street-deck.css +4 -0
  26. package/modules/hall-ui/public/street-office.js +73 -1
  27. package/modules/hall-ui/public/street.html +4 -4
  28. package/modules/hall-ui/public/street.js +4 -1
  29. package/modules/hall-ui/public/studio-lib.js +21 -1
  30. package/modules/hall-ui/public/studio.css +11 -0
  31. package/modules/hall-ui/public/studio.js +8 -1
  32. package/modules/hall-ui/records/living-city.md +3 -1
  33. package/modules/ideas/governor-docket.js +4 -1
  34. package/modules/lifecycle/claimable-counts.js +19 -0
  35. package/modules/lifecycle/criterion-uat-db.js +18 -1
  36. package/modules/lifecycle/criterion-uat.js +12 -3
  37. package/modules/lifecycle/routes/project-settings.js +2 -2
  38. package/modules/lifecycle/routes/tasks.js +10 -10
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +20 -0
  42. package/src/bongos/project-settings.js +5 -4
  43. package/src/module-api.js +1 -1
  44. package/tests/criterion_uat.mjs +42 -0
  45. package/tests/government_tidy_up.mjs +223 -0
  46. package/tests/hall_goals_open_first.mjs +120 -0
  47. package/tests/hall_studio_home.mjs +20 -0
@@ -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) -->
@@ -30,6 +30,16 @@
30
30
  return res.data;
31
31
  }
32
32
 
33
+ const isOpenGoal = (g) => !!g && g.status === 'open';
34
+
35
+ // goal id -> how many claimable tasks it holds. The server counts (?group=goal
36
+ // returns counts and no rows), so this card never downloads the feed itself.
37
+ function claimableByGoal(byGoal) {
38
+ const counts = new Map();
39
+ for (const [k, n] of Object.entries(byGoal || {})) counts.set(String(k), Number(n) || 0);
40
+ return counts;
41
+ }
42
+
33
43
  let host = null;
34
44
  let myId = null;
35
45
  const sameId = (a, b) => a != null && b != null && String(a) === String(b);
@@ -55,7 +65,14 @@
55
65
  return `<span class="goal-badge ${cls}">${escapeHtml(status)}</span>`;
56
66
  }
57
67
 
58
- function row(detail) {
68
+ // task 1004755: the claimable count rides under each open goal, so the card
69
+ // leads with the builder's open goals and shows the work waiting in each.
70
+ function claimableLine(n) {
71
+ if (!n) return '<span class="goal-mine__work card-foot">Nothing claimable right now</span>';
72
+ return `<span class="goal-mine__work card-foot">${n} task${n === 1 ? '' : 's'} you can claim</span>`;
73
+ }
74
+
75
+ function row(detail, counts) {
59
76
  const g = detail.goal;
60
77
  const crit = Array.isArray(detail.criteria) ? detail.criteria : [];
61
78
  const sat = crit.filter((c) => c.satisfied).length;
@@ -68,7 +85,7 @@
68
85
  <a class="goal-mine__link" href="${href}" title="Open ${escapeHtml(g.title || 'goal')}"
69
86
  style="flex:1;display:flex;align-items:center;gap:12px;min-width:0;color:inherit;text-decoration:none;">
70
87
  <span class="goal-mine__ring">${ring(sat, crit.length, achieved)}</span>
71
- <span class="goal-mine__title">${escapeHtml(g.title || 'Untitled goal')}</span>
88
+ <span class="goal-mine__title" style="display:flex;flex-direction:column;min-width:0;">${escapeHtml(g.title || 'Untitled goal')}${claimableLine((counts && counts.get(String(g.id))) || 0)}</span>
72
89
  <span class="goal-mine__meta"><span class="goal-mine__ver">${escapeHtml(g.version_id || '')}</span>${statusBadge(g.status)}</span>
73
90
  </a>
74
91
  </li>`;
@@ -77,21 +94,21 @@
77
94
  // task 2184 (Home rule: a summary card never lists more than 5 rows): cap the
78
95
  // joined-goals list; the overflow count rides the "All goals" foot link.
79
96
  const MAX_HOME_ROWS = 5;
80
- function render(joined) {
97
+ function render(joined, counts) {
81
98
  const body = host.querySelector('#goals-body');
82
99
  if (!joined.length) {
83
- body.innerHTML = '<p class="goal-empty">You haven’t joined any goals yet. <a href="/goals">Browse goals →</a></p>';
100
+ body.innerHTML = '<p class="goal-empty">You haven’t joined any open goals. <a href="/goals">Browse goals →</a></p>';
84
101
  return;
85
102
  }
86
103
  const shown = joined.slice(0, MAX_HOME_ROWS);
87
104
  const more = joined.length - shown.length;
88
- body.innerHTML = `<ul class="goal-mine-list">${shown.map(row).join('')}</ul>` +
105
+ body.innerHTML = `<ul class="goal-mine-list">${shown.map((d) => row(d, counts)).join('')}</ul>` +
89
106
  `<p class="goal-mine__foot card-foot"><a href="/goals">${more > 0 ? `All ${joined.length} goals →` : 'All goals →'}</a></p>`;
90
107
  }
91
108
 
92
109
  const SKELETON =
93
110
  '<h2 id="goals-h" class="scroll__h">Goals</h2>' +
94
- '<p class="scroll__lede">The shared goals you’ve joined. Browse, join, and leave goals — and read their criteria — on the <a href="/goals">Goals page</a>.</p>' +
111
+ '<p class="scroll__lede">The open goals you’ve joined, and the work you can claim in each. Browse, join, and leave goals — and read their criteria — on the <a href="/goals">Goals page</a>.</p>' +
95
112
  '<div id="goals-body"><div class="profile__placeholder">Loading your goals…</div></div>';
96
113
 
97
114
  async function load() {
@@ -107,12 +124,18 @@
107
124
  // paint. boot() no longer mounts widgets for a signed-out visitor; this
108
125
  // keeps the widget itself harmless if it is ever mounted without a session
109
126
  // (an expired cookie mid-visit), where the honest answer is to say nothing.
127
+ // Started now, awaited after the goal reads, so the two run side by side.
128
+ const feedP = getJSON(`${API}/tasks/claimable?group=goal`, { softAuth: true }).catch(() => null);
110
129
  const gres = await getJSON(`${API}/goals`, { softAuth: true });
111
- const candidates = (gres.goals || []).filter((g) => g.status !== 'archived');
130
+ // task 1004755: OPEN goals only. An achieved goal has no work left in it, and
131
+ // listing it here (it took slots in the five-row cap) pushed the goals with
132
+ // work waiting off the card.
133
+ const candidates = (gres.goals || []).filter(isOpenGoal);
112
134
  const details = (await Promise.all(candidates.map((g) =>
113
135
  getJSON(`${API}/goals/${encodeURIComponent(g.id)}`).catch(() => null)))).filter(Boolean);
114
136
  const joined = details.filter((d) => (d.members || []).some((m) => sameId(m.builder_id, myId)));
115
- render(joined);
137
+ const feed = await feedP;
138
+ render(joined, claimableByGoal(feed && feed.by_goal));
116
139
  } catch (err) {
117
140
  const body = host.querySelector('#goals-body');
118
141
  // Signed out is not a failure — it is simply nothing to show. Only a real
@@ -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); }