@bongos/core 1.21.140 → 1.21.142
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +69 -49
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +1 -1
- package/docs/api/openapi.json +3 -4
- package/docs/copy-inventory.md +107 -104
- package/docs/copy-registry.json +156 -129
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +417 -417
- package/modules/copy-desk/page-status.js +51 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +37 -0
- package/modules/government/db.js +6 -0
- package/modules/government/decision-board.js +2 -1
- package/modules/hall-ui/public/city-draw.js +4 -1
- package/modules/hall-ui/public/city.css +1 -1
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/goals.js +31 -8
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +35 -10
- package/modules/hall-ui/public/my-agents.states.json +1 -1
- package/modules/hall-ui/public/settings-interaction.js +7 -2
- package/modules/hall-ui/public/settings-law-locks.js +53 -0
- package/modules/hall-ui/public/settings.html +3 -2
- package/modules/hall-ui/public/settings.js +9 -4
- package/modules/hall-ui/public/street-deck.css +4 -0
- package/modules/hall-ui/public/street-office.js +73 -1
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +4 -1
- package/modules/hall-ui/public/studio-lib.js +21 -1
- package/modules/hall-ui/public/studio.css +11 -0
- package/modules/hall-ui/public/studio.js +8 -1
- package/modules/hall-ui/records/living-city.md +3 -1
- package/modules/ideas/governor-docket.js +4 -1
- package/modules/lifecycle/claimable-counts.js +19 -0
- package/modules/lifecycle/criterion-uat-db.js +18 -1
- package/modules/lifecycle/criterion-uat.js +12 -3
- package/modules/lifecycle/routes/project-settings.js +2 -2
- package/modules/lifecycle/routes/tasks.js +10 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/bongos/project-settings.js +5 -4
- package/src/module-api.js +1 -1
- package/tests/criterion_uat.mjs +42 -0
- package/tests/government_tidy_up.mjs +223 -0
- package/tests/hall_goals_open_first.mjs +120 -0
- package/tests/hall_studio_home.mjs +20 -0
|
@@ -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);
|
package/modules/government/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(--
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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,
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
(
|
|
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); }
|