@bongos/core 1.24.19 → 1.24.21

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 (36) hide show
  1. package/.bongos-core.json +46 -41
  2. package/docs/copy-inventory.md +28 -22
  3. package/docs/copy-registry.json +87 -23
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +698 -698
  6. package/modules/ai-studio/studio.js +1 -1
  7. package/modules/autonomy/governor-docket.js +3 -0
  8. package/modules/government/docket.js +51 -0
  9. package/modules/government/treasury.js +2 -0
  10. package/modules/hall-ui/public/ai-studio-lib.js +74 -9
  11. package/modules/hall-ui/public/ai-studio.css +12 -0
  12. package/modules/hall-ui/public/ai-studio.js +37 -1
  13. package/modules/hall-ui/public/city-decide.js +5 -2
  14. package/modules/hall-ui/public/city.css +1 -0
  15. package/modules/hall-ui/public/gov-living.js +7 -3
  16. package/modules/hall-ui/public/roster.css +43 -0
  17. package/modules/hall-ui/public/street-deck.css +3 -0
  18. package/modules/hall-ui/public/street-deck.js +5 -1
  19. package/modules/hall-ui/records/builders-roster.md +9 -0
  20. package/modules/ideas/governor-docket.js +3 -0
  21. package/modules/lifecycle/governor-docket.js +26 -0
  22. package/modules/onboarding/routes/access-requests.js +2 -0
  23. package/modules/security/routes/reports.js +2 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +16 -0
  27. package/src/bongos/module-rechecks.js +3 -0
  28. package/src/bongos/routes/core-update.js +2 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/city_card_context.mjs +139 -0
  31. package/tests/government_treasury.mjs +1 -1
  32. package/tests/governor_docket.mjs +3 -1
  33. package/tests/hall_ai_studio.mjs +44 -1
  34. package/tests/hall_gov_real_site.mjs +1 -1
  35. package/tests/hall_roster_world.mjs +10 -0
  36. package/tests/hall_street_depth.mjs +7 -1
@@ -100,7 +100,7 @@ function buildContext({ branding, projectDoc, designDoc }) {
100
100
  const palette = branding && branding.theme && branding.theme.colors ? JSON.stringify(branding.theme.colors).slice(0, 800) : '';
101
101
  const parts = [
102
102
  `You are the art director's assistant for ${id.productName || 'this project'}${id.worldName && id.worldName !== id.productName ? `, set in the world of ${id.worldName}` : ''}.`,
103
- 'An artist on the project has sent a sketch or reference images with a request. Answer as a thoughtful collaborator: say what you see, what works, and how to take it further, in plain words.',
103
+ 'An artist on the project has sent a sketch or reference images with a request. Answer as a thoughtful collaborator: say what you see, what works, and how to take it further, in plain words. Keep it short and friendly for a visual artist: a few short paragraphs or a brief list, no code, no markup symbols, and no technical file formats unless they asked for one.',
104
104
  'Keep everything you suggest consistent with the project as described below.',
105
105
  ];
106
106
  if (palette) parts.push(`## Brand colours\n${palette}`);
@@ -58,6 +58,9 @@ function fenceDocketMatters(project, mayManage) {
58
58
  tally: null,
59
59
  href: '/watch',
60
60
  ref: { type: 'autonomy_fence', id: 'project' },
61
+ context: project.enabled === false
62
+ ? 'Unattended building lets AI runners claim and build tasks on their own, with nobody at the keyboard. It is switched off, so no runner builds anything until someone turns it on.'
63
+ : 'Unattended building lets AI runners claim and build tasks on their own. It is on, but no goal is on its allowed list, so the runners have nothing they may work on yet.',
61
64
  ...(detail ? { detail } : {}),
62
65
  // The floor desk's acts (task 1004559), only for the switch's own atom.
63
66
  ...(mayManage ? { acts: project.enabled === false ? [{ key: 'switch_on' }] : [{ key: 'allow', pick: 'open_goals', except: [] }, { key: 'switch_off', options: OFF_REASONS }] } : {}),
@@ -165,12 +165,44 @@ function tally(t) {
165
165
  };
166
166
  }
167
167
 
168
+ // WHAT A CARD IS ABOUT (task 1005013). A matter may carry `context`: a few plain
169
+ // sentences from the module that owns it saying what kind of thing this is, what
170
+ // it belongs to (a goal, a task, a module, by name), why it waits and what
171
+ // answering does — so a card is never only a bare title. A matter that brings
172
+ // none gets its source's sentence below; every source the design names has one.
173
+ // It may also carry `question`: the plain question a card asks when no agent
174
+ // stored an ask, in place of the raw title.
175
+ const SOURCE_CONTEXT = Object.freeze({
176
+ board: 'A sitting of the board. Its members vote, and the result is decided by the rule in the charter.',
177
+ blockers: 'A blocker: work that cannot go on until a person decides something. Deciding it releases the work that waits on it.',
178
+ access_requests: 'Someone asked to join this project as a builder. Admitting them lets them sign in and claim work; nobody joins until someone with the right says yes.',
179
+ goal_join_requests: 'Someone asked to join a goal team. Members of a goal may claim its tasks.',
180
+ override_requests: 'A request to let finished work through although it failed its automatic check or grade. Approving it treats the work as passed.',
181
+ removal_proposals: 'A later change deleted work that had already shipped. A person confirms whether that work is really gone, or keeps it counted.',
182
+ redteam_reports: 'A security report: someone says they found a flaw in the project itself. Confirming it means the flaw is real and needs a fix; disputing it means it is not.',
183
+ criteria_uat: 'A done-when promise of a goal. The work for it has shipped; a person checks it on the live site and signs it off, which closes the promise.',
184
+ core_update: 'A newer version of Bongos, the software this project runs on, is available. Taking it updates the project to that version.',
185
+ autobongos_fence: 'The switch for unattended building: whether AI runners may build on their own, and which goals they may work on.',
186
+ module_rechecks: 'A module (an add-on to the project) is being checked again for safety. One that failed waits for someone to run the check again.',
187
+ treasury_spending: 'A goal has used up its money for the month and asks to borrow from the reserve so its work can go on.',
188
+ });
189
+ const MAX_CONTEXT = 600;
190
+ const MAX_QUESTION = 160;
191
+ function clip(v, max) {
192
+ if (typeof v !== 'string') return null;
193
+ const t = v.replace(/\s+/g, ' ').trim();
194
+ if (!t) return null;
195
+ return t.length <= max ? t : t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';
196
+ }
197
+
168
198
  // One matter as a contributor handed it → the docket's one shape. Pure. Unknown
169
199
  // rule → null (the card says so), unknown state → 'moving' (never a false
170
200
  // "needs you"), unknown building → the source's own building.
171
201
  function normalizeMatter(raw, { source, building }) {
172
202
  if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
173
203
  const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
204
+ const context = clip(raw.context, MAX_CONTEXT) || SOURCE_CONTEXT[source] || null;
205
+ const question = clip(raw.question, MAX_QUESTION);
174
206
  return {
175
207
  id: `${source}:${raw.id}`,
176
208
  source,
@@ -186,6 +218,8 @@ function normalizeMatter(raw, { source, building }) {
186
218
  tally: tally(raw.tally),
187
219
  href: raw.href ? String(raw.href) : null,
188
220
  ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
221
+ ...(context ? { context } : {}),
222
+ ...(question ? { question } : {}),
189
223
  ...actsField(raw.acts),
190
224
  ...detailField(raw.detail),
191
225
  };
@@ -364,6 +398,13 @@ const PASS_RULE_NEEDS = Object.freeze({
364
398
  majority: 'needs more than half',
365
399
  unanimous: 'needs everyone who votes',
366
400
  });
401
+ // the same rule as the last sentence of a sitting's context (task 1005013)
402
+ const PASS_RULE_SENTENCE = Object.freeze({
403
+ first_ratifier: 'One yes decides it.',
404
+ consent: 'It passes unless someone objects.',
405
+ majority: 'It needs a yes from more than half the members.',
406
+ unanimous: 'It needs a yes from everyone who votes.',
407
+ });
367
408
 
368
409
  // One open or recently closed sitting, for one viewer. Pure.
369
410
  //
@@ -402,6 +443,14 @@ function boardMatter(item, { viewerId, members }) {
402
443
  : item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
403
444
  : item.subject_type === 'genesis_stage' ? 'charter · founding'
404
445
  : 'planning · idea to ratify';
446
+ const passWords = PASS_RULE_SENTENCE[constitution.pass_rule] ? ' ' + PASS_RULE_SENTENCE[constitution.pass_rule] : '';
447
+ const context = (matterArea
448
+ ? `A decision put to a vote at ${building}: the charter says matters here are decided by a sitting, not by one person acting alone. A yes carries out what it proposes.`
449
+ : item.subject_type === 'constitutional_amendment'
450
+ ? 'A proposed change to the charter, the rules for who decides what in this project. If it passes, the new rule takes effect at once.'
451
+ : item.subject_type === 'genesis_stage'
452
+ ? 'A founding stage of this project has finished its work. Closing it moves the project on to the next stage.'
453
+ : 'A full idea someone wrote up, put to the board. If it passes, it becomes part of the plan: a new goal or a done-when promise of one.') + passWords;
405
454
  const forN = votes.filter((v) => v.direction === 'yes').length;
406
455
  const againstN = votes.filter((v) => v.direction === 'no').length;
407
456
  const castIds = new Set(votes.map((v) => String(v.voter_id)));
@@ -433,6 +482,7 @@ function boardMatter(item, { viewerId, members }) {
433
482
  tally: { for: forN, against: againstN, open: isOpen ? open : 0, needs: PASS_RULE_NEEDS[constitution.pass_rule] || null },
434
483
  href: `/board-room?item=${encodeURIComponent(item.id)}`,
435
484
  ref: { type: 'board_item', id: item.id },
485
+ context,
436
486
  };
437
487
  }
438
488
 
@@ -962,6 +1012,7 @@ module.exports = {
962
1012
  normalizeDeskRow,
963
1013
  loadGovernment,
964
1014
  normalizeMatter,
1015
+ SOURCE_CONTEXT,
965
1016
  gateAllows,
966
1017
  homeLine,
967
1018
  boardMatter,
@@ -227,6 +227,8 @@ function borrowMatter(borrow, { title, canAnswer = false } = {}) {
227
227
  deadline: null,
228
228
  href: `/goal/${b.goal_id}`,
229
229
  ref: { type: 'pot_borrow', id: b.id },
230
+ question: `Lend ${name} ${usd(b.amount_usd)} from the reserve?`,
231
+ context: `Each goal gets a monthly pot of money for its AI work. ${name} has spent ${usd(b.spent_usd)} of its ${usd(b.budget_usd)} this month, so its work stops until it gets more. Lending from the reserve lets it go on; saying no pauses it until next month.`,
230
232
  detail: `${name} has spent ${usd(b.spent_usd)} of its ${usd(b.budget_usd)} pot this month and asks the reserve for ${usd(b.amount_usd)}.`,
231
233
  ...(canAnswer ? { acts: [{ key: ACT_KEY, options: [
232
234
  { key: 'lend', label: `Lend ${usd(b.amount_usd)}` }, { key: 'half', label: `Lend ${usd(half)}` }, { key: 'no', label: 'Say no' },
@@ -98,6 +98,48 @@
98
98
  }));
99
99
  }
100
100
 
101
+ // What a request will probably cost, from this builder's own finished ones
102
+ // paid the same way: the median of what we billed (ours) or of the cost the
103
+ // provider reported (their own key). max is the most one can ever cost on
104
+ // our key (max_cost_micro).
105
+ // typical is null when there is no history yet: the page then says so
106
+ // instead of inventing a number.
107
+ function estimate(requests, maxMicro, ours) {
108
+ const costs = (requests || [])
109
+ .filter((r) => r && r.status === 'done' && (r.key_source === 'ours') === !!ours)
110
+ .map((r) => (ours ? r.price_micro : r.provider_cost_micro))
111
+ .filter((n) => isMicro(n) && n > 0)
112
+ .sort((a, b) => a - b);
113
+ const max = isMicro(maxMicro) ? maxMicro : null;
114
+ if (!costs.length) return { typical: null, max, basis: 0 };
115
+ const mid = Math.floor(costs.length / 2);
116
+ const median = costs.length % 2 ? costs[mid] : Math.round((costs[mid - 1] + costs[mid]) / 2);
117
+ return { typical: max === null ? median : Math.min(median, max), max, basis: costs.length };
118
+ }
119
+
120
+ // Google reports no price on a builder's own Gemini key, so the page says
121
+ // "about" and works it out from Google's published rates: a written reply
122
+ // (gemini-2.5-pro, a few thousand tokens in and about a thousand out) and
123
+ // each picture (gemini-2.5-flash-image, about $0.039 each). The two numbers
124
+ // are named here and spelled nowhere else; update them if Google's change.
125
+ const GEMINI_REPLY_MICRO = 25000;
126
+ const GEMINI_IMAGE_MICRO = 39000;
127
+ function geminiEstimate(imageCount) {
128
+ const n = Math.max(1, Math.floor(Number(imageCount) || 0));
129
+ return GEMINI_REPLY_MICRO + n * GEMINI_IMAGE_MICRO;
130
+ }
131
+ const outputCount = (r) => ((r && r.files) || []).filter((f) => f.role === 'output').length;
132
+
133
+ // The headline cost on the result: one sentence an artist can read at a glance.
134
+ function costHeadline(req) {
135
+ const r = req || {};
136
+ if (r.status !== 'done') return '';
137
+ if (r.key_source === 'ours' && isMicro(r.price_micro)) return `This cost ${usd(r.price_micro)}, taken from your balance.`;
138
+ if (r.key_source === 'own' && isMicro(r.provider_cost_micro)) return `This cost ${usd(r.provider_cost_micro)}, billed to your own key.`;
139
+ if (r.key_source === 'own') return `This cost about ${usd(geminiEstimate(outputCount(r)))} on your Gemini key.`;
140
+ return '';
141
+ }
142
+
101
143
  // The prompt as one quoted line for step 3's summary row.
102
144
  function quoteLine(prompt, max) {
103
145
  const t = String(prompt || '').replace(/\s+/g, ' ').trim();
@@ -189,8 +231,8 @@
189
231
  }
190
232
  if (r.key_source === 'own') {
191
233
  return {
192
- rows: [['Paid with', 'your own Gemini key'], ['Cost', 'not reported']],
193
- note: "Google doesn't tell us the price on your own key, so check your Google bill. We charged nothing.",
234
+ rows: [['Paid with', 'your own Gemini key'], ['Cost', `about ${usd(geminiEstimate(outputCount(r)))}`]],
235
+ note: "Google doesn't tell us the exact price on your own key, so this is an estimate. Your Google bill has the real figure. We charged nothing.",
194
236
  };
195
237
  }
196
238
  return { rows: [], note: '' };
@@ -238,15 +280,38 @@
238
280
  }
239
281
  }
240
282
 
241
- // The model's reply as paragraphs, **bold** kept, everything else escaped by
242
- // the hall's ONE escaper, handed in (window.OTB.escapeHtml in the page).
283
+ // The model's reply read the way an artist wants to see it: headings as
284
+ // headings, bullets as a list, **bold** and `names` kept, divider dashes
285
+ // dropped, and any code listing folded away behind one line instead of
286
+ // pouring down the page. Every character of text goes through the hall's ONE
287
+ // escaper, handed in (window.OTB.escapeHtml in the page), before any tag is
288
+ // added.
243
289
  function replyHtml(md, escape) {
244
290
  const text = String(md || '').replace(/\r\n/g, '\n').trim();
245
291
  if (!text || typeof escape !== 'function') return '';
246
- return text.split(/\n{2,}/).map((para) => {
247
- const safe = escape(para.trim()).replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
248
- return `<p>${safe}</p>`;
249
- }).join('');
292
+ const inline = (s) => escape(s).replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>').replace(/`([^`\n]+)`/g, '<code>$1</code>');
293
+ const out = [];
294
+ let para = [];
295
+ let list = [];
296
+ let code = null;
297
+ const flushPara = () => { if (para.length) out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; };
298
+ const flushList = () => { if (list.length) out.push(`<ul>${list.map((t) => `<li>${inline(t)}</li>`).join('')}</ul>`); list = []; };
299
+ for (const raw of text.split('\n')) {
300
+ const line = raw.trimEnd();
301
+ if (/^\s*```/.test(line)) {
302
+ if (code) { out.push(`<details class="ais-code"><summary>Code from the answer</summary><pre>${escape(code.join('\n'))}</pre></details>`); code = null; } else { flushPara(); flushList(); code = []; }
303
+ continue;
304
+ }
305
+ if (code) { code.push(raw); continue; }
306
+ const head = /^#{1,6}\s+(.+)$/.exec(line);
307
+ const item = /^\s*[*•-]\s+(.+)$/.exec(line);
308
+ if (!line.trim()) { flushPara(); flushList(); } else if (/^\s*-{2,}\s*$/.test(line)) { flushPara(); flushList(); } else if (head) {
309
+ flushPara(); flushList(); out.push(`<h3 class="ais-rh">${inline(head[1].replace(/\*\*/g, ''))}</h3>`);
310
+ } else if (item) { flushPara(); list.push(item[1]); } else { flushList(); para.push(line.trim()); }
311
+ }
312
+ if (code) out.push(`<details class="ais-code"><summary>Code from the answer</summary><pre>${escape(code.join('\n'))}</pre></details>`);
313
+ flushPara(); flushList();
314
+ return out.join('');
250
315
  }
251
316
 
252
317
  // The home's AI action line: the newest request, while it is being made or
@@ -266,7 +331,7 @@
266
331
 
267
332
  const lib = {
268
333
  MAX_FILES, MAX_IMAGE_BYTES, MAX_PDF_BYTES, IMAGE_TYPES, PDF_TYPE, STEPS, TOPUP_CHOICES, ANSWER_AFTER_SEC,
269
- usd, balanceUsd, sizeText, routeOf, checkFile, isImage, stepMarks, quoteLine, ownKey, ownKeys, payChoices,
334
+ usd, balanceUsd, sizeText, routeOf, checkFile, isImage, stepMarks, quoteLine, ownKey, ownKeys, payChoices, estimate, geminiEstimate, costHeadline,
270
335
  waitSteps, clock, isFinished, receipt, againLine, monthSummary, ledgerLine, replyHtml, homeLine,
271
336
  };
272
337
  if (typeof module !== 'undefined' && module.exports) module.exports = lib;
@@ -138,12 +138,24 @@
138
138
  .ais-pick img { display: block; width: 100%; height: 100%; object-fit: cover; }
139
139
  .ais-pick--on { outline: 2px solid var(--st-accent); outline-offset: 0; }
140
140
  .ais-reply p { margin: 0 0 12px; font-size: 15px; line-height: 1.6; color: var(--st-ink-sub); font-weight: 300; }
141
+ .ais-rh { margin: 18px 0 6px; font-size: 16px; font-weight: 500; color: var(--st-ink); }
142
+ .ais-reply ul { margin: 0 0 12px; padding-left: 20px; font-size: 15px; line-height: 1.6; color: var(--st-ink-sub); font-weight: 300; }
143
+ .ais-reply code { font-size: 13.5px; }
144
+ .ais-code { margin: 0 0 12px; font-size: 13.5px; color: var(--st-ink-faint); }
145
+ .ais-code summary { cursor: pointer; min-height: 24px; }
146
+ .ais-code pre { margin: 8px 0 0; padding: 12px; overflow-x: auto; border-radius: 12px; background: var(--st-glass); white-space: pre; }
141
147
  .ais-reply b { color: var(--st-ink); font-weight: 500; }
142
148
  .ais-again { display: flex; flex-direction: column; gap: 8px; }
143
149
  .ais-receipt, .ais-topup {
144
150
  display: flex; flex-direction: column; padding: 10px 20px; border-radius: 22px;
145
151
  background: var(--st-glass); border: 1px solid var(--st-glass-edge);
146
152
  }
153
+ .ais-confirm {
154
+ display: flex; flex-direction: column; gap: 8px; padding: 16px 20px; border-radius: 22px;
155
+ background: var(--st-glass); border: 1px solid var(--st-glass-edge);
156
+ }
157
+ .ais-confirm__head { margin: 0; font-size: 20px; font-weight: 500; color: var(--st-ink); }
158
+ .ais-cost { margin: 0 0 12px; font-size: 17px; font-weight: 500; color: var(--st-ink); }
147
159
  .ais-line { display: flex; align-items: center; gap: 14px; min-height: 40px; border-top: 1px solid var(--st-track); font-size: 14px; color: var(--st-ink); }
148
160
  .ais-line:first-child, .ais-head + .ais-line { border-top: 0; }
149
161
  .ais-line a { color: var(--st-link); display: inline-flex; align-items: center; min-height: 24px; }
@@ -352,13 +352,47 @@
352
352
  <h1 class="ais-ask" data-head tabindex="-1">How do you want to pay?</h1>
353
353
  <div class="ais-row ais-row--summary">${thumbs}<span class="ais-grow ais-soft">${esc(lib.quoteLine(state.draft.prompt))}</span><a class="ais-link" href="/builders/ai-studio?step=describe" data-nav="step=describe">change</a></div>
354
354
  ${ownCard || balanceCard ? `<div class="ais-cards">${ownCard}${balanceCard}</div>` : ''}
355
+ <div class="ais-confirm" id="ais-confirm" role="group" aria-label="Confirm the cost" hidden></div>
355
356
  <p class="ais-faint">${esc(foot)}</p>
356
357
  ${extra}
357
358
  <p class="ais-err" id="ais-err" role="alert" hidden></p>`;
358
- page.querySelectorAll('[data-pay]').forEach((b) => b.addEventListener('click', () => sendRequest(b.getAttribute('data-pay'), state.draft.prompt, readyFiles(), b)));
359
+ page.querySelectorAll('[data-pay]').forEach((b) => b.addEventListener('click', () => askToConfirm(b.getAttribute('data-pay'), c)));
359
360
  wireNavLinks();
360
361
  }
361
362
 
363
+ // Before anything is sent or set aside: say what it will probably cost and
364
+ // wait for a yes. Past runs set the guess; the ceiling is what a run can
365
+ // never exceed.
366
+ async function askToConfirm(payWith, choices) {
367
+ if (state.busy) return;
368
+ const own = payWith !== 'ours';
369
+ if (payWith !== 'gemini' && !state.history) {
370
+ const res = await api.request('GET', `${API}/ai-studio/requests`);
371
+ state.history = res.ok && res.data ? res.data.requests : [];
372
+ }
373
+ const est = lib.estimate(state.history || [], state.balance && state.balance.max_cost_micro, !own);
374
+ const typical = payWith === 'gemini' ? lib.geminiEstimate(1) : est.typical;
375
+ const guess = typical === null ? 'A few cents' : `About ${lib.usd(typical)}`;
376
+ const ceiling = own || est.max === null ? '' : `It will never cost more than ${lib.usd(est.max)}.`;
377
+ const who = own ? 'Your own key pays, so nothing comes out of your balance here.' : `Taken from your balance (${choices.left}), and anything unused comes back.`;
378
+ const basis = payWith === 'gemini' ? 'Google reports no price on your own key, so this is worked out from its published rates: one reply and one picture.' : est.basis ? `Based on your last ${est.basis === 1 ? 'request' : `${est.basis} requests`}.` : 'You have no finished requests yet, so this is a rough guess.';
379
+ const box = page.querySelector('#ais-confirm');
380
+ if (!box) return;
381
+ box.hidden = false;
382
+ box.innerHTML = `
383
+ <h2 class="ais-confirm__head" data-confirm-head tabindex="-1">${esc(guess)} to make this</h2>
384
+ <p class="ais-soft">${esc([ceiling, who].filter(Boolean).join(' '))}</p>
385
+ <p class="ais-faint">${esc(basis)}</p>
386
+ <div class="ais-go ais-go--wrap">
387
+ <button type="button" class="ais-btn" id="ais-confirm-go">Make it</button>
388
+ <button type="button" class="ais-ghost" id="ais-confirm-cancel">Not yet</button>
389
+ </div>`;
390
+ const head = box.querySelector('[data-confirm-head]');
391
+ if (head) head.focus();
392
+ box.querySelector('#ais-confirm-cancel').addEventListener('click', () => { box.hidden = true; box.innerHTML = ''; });
393
+ box.querySelector('#ais-confirm-go').addEventListener('click', (ev) => sendRequest(payWith, state.draft.prompt, readyFiles(), ev.currentTarget));
394
+ }
395
+
362
396
  // Same-page links move through history, so the steps keep the back button.
363
397
  function wireNavLinks() {
364
398
  page.querySelectorAll('[data-nav]').forEach((a) => a.addEventListener('click', (ev) => {
@@ -465,6 +499,7 @@
465
499
  const rc = lib.receipt(req, state.balance && state.balance.price_multiplier);
466
500
  const receiptHtml = rc.rows.length ? `<div class="ais-receipt">${rc.rows.map(([k, v]) => `<div class="ais-line"><span class="ais-grow">${esc(k)}</span><span>${esc(v)}</span></div>`).join('')}${rc.note ? `<div class="ais-line"><span class="ais-grow ais-faint ais-wrap">${esc(rc.note)}</span></div>` : ''}</div>` : '';
467
501
  const failed = req.status !== 'done';
502
+ const headline = lib.costHeadline(req);
468
503
  main.setAttribute('data-view', 'result');
469
504
  page.innerHTML = `
470
505
  <h1 class="ais-sr" data-head tabindex="-1">${failed ? 'That did not work' : 'Your result'}</h1>
@@ -476,6 +511,7 @@
476
511
  </div>
477
512
  <div class="ais-result__words">
478
513
  ${failed ? `<p class="ais-err">${esc(req.error || 'It did not work, and nothing was charged.')}</p>` : ''}
514
+ ${headline ? `<p class="ais-cost" data-cost>${esc(headline)}</p>` : ''}
479
515
  <div class="ais-reply">${lib.replyHtml(req.reply_md, esc)}</div>
480
516
  <div class="ais-again">
481
517
  <label class="ais-head" for="ais-again">Your request</label>
@@ -83,6 +83,7 @@
83
83
  var lines = ['I need to make a decision in my project and want help understanding it.', ''];
84
84
  lines.push('Where: the ' + str(cv.buildingName) + ' building' + (cv.waits ? ' (' + str(cv.waits) + ')' : '') + '.');
85
85
  lines.push('The question: ' + str(cv.question));
86
+ if (cv.context) lines.push('What it is: ' + str(cv.context));
86
87
  if (cv.said) lines.push('What it says: ' + str(cv.said));
87
88
  var card = cv.card || {};
88
89
  var more = card.more && card.more.text ? str(card.more.text) : str(card.fact || '');
@@ -255,8 +256,10 @@
255
256
  function drawCard(doc, K, cv, on, o) {
256
257
  var shown = answerable(cv);
257
258
  var matter = matterOf(on.docket && on.docket(), cv.id);
258
- // the question in full: a matter with no stored card is titled by its own words, cut to fit the deck
259
- if (matter && !cv.askReal && matter.title) shown.question = questionOf(matter.title);
259
+ // the question in full: a matter with no stored card asks its own plain question
260
+ // when it wrote one (task 1005013), else is titled by its own words, cut to fit the deck
261
+ if (matter && !cv.askReal && matter.question) shown.question = questionOf(matter.question);
262
+ else if (matter && !cv.askReal && matter.title) shown.question = questionOf(matter.title);
260
263
  var art = K.drawAsk(doc, shown, on, o);
261
264
  // and everything else it says, one tap away rather than cut
262
265
  var full = fullText(matter, shown);
@@ -231,6 +231,7 @@ html[data-fullscreen="on"] .city-top { right: 184px; }
231
231
  .city-card .city-ask.ask { padding: 14px 16px; gap: 10px; }
232
232
  .city-card .city-ask .ask__q { font-size: 17px; line-height: 1.3; font-weight: 400; }
233
233
  .city-card .city-ask .ask__said { font-size: 13px; }
234
+ .city-card .city-ask .ask__ctx { font-size: 13px; line-height: 1.45; }
234
235
  .city-card .city-ask .ask__opt { min-height: 44px; padding: 6px 12px 6px 10px; border-radius: 14px; }
235
236
  .city-card .city-ask .ask__label { font-size: 14px; }
236
237
  .city-card .city-ask .ask__result { font-size: 12px; }
@@ -338,6 +338,9 @@
338
338
 
339
339
  // ---------- one ask card ----------
340
340
  const STATE_ORDER = Object.freeze({ needs: 1, moving: 2, settled: 3 });
341
+ // a blocker read straight from GET /blockers carries no docket context; this is
342
+ // the docket's own sentence for one (modules/government/docket.js SOURCE_CONTEXT)
343
+ const BLOCKER_CONTEXT = 'A blocker: work that cannot go on until a person decides something. Deciding it releases the work that waits on it.';
341
344
  function makeAsk(base, human, ctx, sources) {
342
345
  const opened = time(base.opened_at);
343
346
  const deadline = time(base.deadline);
@@ -375,7 +378,8 @@
375
378
  href: base.href || null,
376
379
  acts: base.acts || [],
377
380
  tally: base.tally || null,
378
- raw: { title: str(base.raw_title), detail: str(base.raw_detail), slop: slopScore(rawText) },
381
+ // what the card is about and the plain question it asks (task 1005013)
382
+ raw: { title: str(base.raw_title), detail: str(base.raw_detail), slop: slopScore(rawText), context: str(base.raw_context), question: str(base.raw_question) },
379
383
  human: human || null,
380
384
  format,
381
385
  shown: format.ok ? human : null,
@@ -661,7 +665,7 @@
661
665
  const stored = storedOf(m.ask);
662
666
  asks.push(makeAsk({
663
667
  id: m.id, source: m.source, building, state: m.state, opened_at: m.opened_at, deadline: m.deadline,
664
- rule: m.rule, ref: m.ref, href: m.href, acts: m.acts, tally: m.tally, raw_title: m.title, raw_detail: m.detail, from_docket: true, ask_real: !!stored, route: m.route,
668
+ rule: m.rule, ref: m.ref, href: m.href, acts: m.acts, tally: m.tally, raw_title: m.title, raw_detail: m.detail, raw_context: m.context, raw_question: m.question, from_docket: true, ask_real: !!stored, route: m.route,
665
669
  }, humanOf(stored), ctx, ASK_SOURCES));
666
670
  });
667
671
  arr(o.blockers, 'blockers').forEach((b) => {
@@ -672,7 +676,7 @@
672
676
  const stored = storedOf(b.ask);
673
677
  asks.push(makeAsk({
674
678
  id, source: 'blockers', building: 'planning', state: 'needs', opened_at: b.created_at, deadline: null,
675
- rule: 'decide', ref: { type: 'blocker', id: String(b.id) }, href: '/blockers#blocker-' + b.id, acts: [], raw_title: b.title, raw_detail: b.body_md, ask_real: !!stored,
679
+ rule: 'decide', ref: { type: 'blocker', id: String(b.id) }, href: '/blockers#blocker-' + b.id, acts: [], raw_title: b.title, raw_detail: b.body_md, raw_context: BLOCKER_CONTEXT, ask_real: !!stored,
676
680
  }, humanOf(stored), ctx, ASK_SOURCES));
677
681
  });
678
682
  asks.sort(compareAsks);
@@ -52,3 +52,46 @@
52
52
  font-size: 11.5px;
53
53
  color: var(--ink-faint);
54
54
  }
55
+
56
+ /* ---- the circuit divider (task 1005032, the owner's Studio idea 1001297; mock:
57
+ docs/design/mocks/builders-leaderboard/) -----------------------------------
58
+ A trace between the column headings and the first row: a fixed cap at each
59
+ end, a hairline stretched between them, and orange nodes on the traces. It is
60
+ drawn on the FIRST BODY ROW (its top 30px, which the row's own top padding
61
+ makes room for) rather than under the sticky heading, so it scrolls with the
62
+ rows and never strands a blank band under a stuck header. Two masks, two
63
+ tokens: the traces are --ink-faint, the nodes --accent, so both modes follow
64
+ the world. The empty-state row has no trace. Pure decoration, no markup. */
65
+ #roster-body .ledger th { border-bottom-color: transparent; }
66
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) { position: relative; }
67
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td { padding-top: 44px; }
68
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::before,
69
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::after {
70
+ content: ""; position: absolute; left: 0; right: 0; top: 0; height: 30px;
71
+ pointer-events: none;
72
+ -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
73
+ }
74
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::before {
75
+ background: var(--ink-faint);
76
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V6H100V14H156V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 22H80M104 12H124M132 14H152M160 16H180M92 10V24'/%3E%3C/g%3E%3C/svg%3E"),
77
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V22H168V28H196V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 14H70M110 10H128M136 16H160M170 4V14M200 8V24'/%3E%3C/g%3E%3C/svg%3E"),
78
+ linear-gradient(black, black);
79
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V6H100V14H156V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 22H80M104 12H124M132 14H152M160 16H180M92 10V24'/%3E%3C/g%3E%3C/svg%3E"),
80
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V22H168V28H196V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 14H70M110 10H128M136 16H160M170 4V14M200 8V24'/%3E%3C/g%3E%3C/svg%3E"),
81
+ linear-gradient(black, black);
82
+ -webkit-mask-size: 300px 30px, 300px 30px, calc(100% - 598px) 1.5px;
83
+ mask-size: 300px 30px, 300px 30px, calc(100% - 598px) 1.5px;
84
+ -webkit-mask-position: left top, right top, 299px 17.25px;
85
+ mask-position: left top, right top, 299px 17.25px;
86
+ }
87
+ #roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::after {
88
+ background: var(--accent);
89
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='88' cy='6' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='88' cy='6' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E"),
90
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='196' cy='28' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='196' cy='28' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E");
91
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='88' cy='6' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='88' cy='6' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E"),
92
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='196' cy='28' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='196' cy='28' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E");
93
+ -webkit-mask-size: 300px 30px, 300px 30px;
94
+ mask-size: 300px 30px, 300px 30px;
95
+ -webkit-mask-position: left top, right top;
96
+ mask-position: left top, right top;
97
+ }
@@ -123,6 +123,8 @@ body[data-page="street"] .street-toast { z-index: 13; }
123
123
  it is not a control, so it draws no ring */
124
124
  .ask__q:focus, .ask__q:focus-visible { outline: none; box-shadow: none; }
125
125
  .ask__said { margin: -4px 0 0; font-size: 15.5px; line-height: 1.5; color: var(--ink-soft); }
126
+ /* what the card is about, under its question (task 1005013) */
127
+ .ask__ctx { margin: -4px 0 0; font-size: 14.5px; line-height: 1.5; color: var(--ink); overflow-wrap: anywhere; }
126
128
  .ask__likes { margin: -2px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
127
129
  .ask__likes li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.4; color: var(--ink); }
128
130
  .ask__likes li::before { content: ""; flex: none; width: 5px; height: 5px; margin-top: 8px; border-radius: 50%; background: var(--ink-faint); }
@@ -160,6 +162,7 @@ body[data-page="street"] .street-toast { z-index: 13; }
160
162
  .ask--here { gap: 14px; padding: 18px 18px 16px; border-radius: 22px; background: var(--gov-chip); border: 1px solid var(--gov-chip-edge); }
161
163
  .ask--here .ask__q { font-size: 23px; }
162
164
  .ask--here .ask__said { font-size: 14.5px; }
165
+ .ask--here .ask__ctx { font-size: 14px; }
163
166
  .ask--here .ask__opt { min-height: 54px; border-radius: 15px; }
164
167
 
165
168
  /* a building's card on the street: glass beside its pin */
@@ -213,11 +213,14 @@
213
213
  routing: shown && shown.routing_note ? str(shown.routing_note) : '', tally: ask.tally || null, kind: ask.kind || null,
214
214
  // a board's vote shows its seats, one pip each, lit as the sitting's tally says
215
215
  seats: boardSeats(c.docket, ask.waits_on ? str(ask.waits_on.seat) : ''),
216
+ // what this is and what it belongs to, in plain words (task 1005013)
217
+ context: ask.raw ? plain(ask.raw.context) : '',
216
218
  card: cv,
217
219
  };
218
220
  if (!shown) {
219
221
  // no stored ask: the matter in its own words, and its floor's desk, where its acts are
220
- view.question = fit(plain(ask.raw ? ask.raw.title : ''), 90);
222
+ // the matter's own plain question when it wrote one, else its title (task 1005013)
223
+ view.question = ask.raw && ask.raw.question ? fit(plain(ask.raw.question), 120) : fit(plain(ask.raw ? ask.raw.title : ''), 90);
221
224
  view.desk = '/builders/street#building=' + str(ask.building);
222
225
  view.foot = footOf(ask, '');
223
226
  return view;
@@ -436,6 +439,7 @@
436
439
  q.setAttribute('tabindex', '-1');
437
440
  if (o.qid) q.setAttribute('id', o.qid);
438
441
  card.appendChild(q);
442
+ if (cv.context) card.appendChild(h('p', 'ask__ctx', cv.context));
439
443
  if (cv.batch && cv.likes) {
440
444
  const list = h('ul', 'ask__likes');
441
445
  cv.likes.forEach((l) => list.appendChild(h('li', '', l.question)));
@@ -28,3 +28,12 @@ Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js
28
28
  - **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
29
29
  - **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
30
30
  - **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
31
+
32
+ ## The circuit divider (task 1005032)
33
+
34
+ The owner's Studio idea 1001297 (a circuit-trace image as a table divider; mock `docs/design/mocks/builders-leaderboard/`, task 1005030) now sits between the roster's column headings and its first row. It is **CSS only, in `roster.css`**: the ledger kit is untouched and no markup was added.
35
+
36
+ - **It is drawn on the first BODY row, not under the sticky heading.** A divider hung under the sticky `th` would stay stuck while the rows scrolled away, leaving a blank band; on the first row it scrolls with the data. The row's top padding makes room for it.
37
+ - **The pseudo-elements sit on the row's first `td`, never on the `tr`.** On the `tr` they became two extra table cells and pushed every cell in the row two columns right (seen in the first render). The `tr` only carries `position: relative`, so the `td`'s absolute children size against the whole row.
38
+ - **Two masks, two tokens.** Traces are `--ink-faint` and the orange nodes `--accent`, each shown through an SVG mask, so light and dark follow the world with no hard-coded colour. Left cap, right cap and the stretching hairline are three layers of one mask.
39
+ - **The empty-state row has no trace** (`:not(:has(.ledger__empty))`). Checked at desktop and 375px, light and dark: the table scrolls inside its card on a phone and the divider spans it; the page does not scroll sideways.
@@ -45,6 +45,9 @@ function blockerDocketMatter(b, mayResolve) {
45
45
  tally: null,
46
46
  href: `/blockers#blocker-${b.id}`,
47
47
  ref: { type: 'blocker', id: b.id },
48
+ context: holds
49
+ ? `A blocker: ${holds === 1 ? 'one task is' : `${holds} tasks are`} stopped until a person decides this. Deciding it lets that work go on.`
50
+ : 'A blocker: an AI session or a builder is stuck until a person decides this. Deciding it lets the work go on.',
48
51
  // the card's "more" (task 1004566): the body GET /blockers shows everyone
49
52
  detail: b.body_md || null,
50
53
  // the floor desk's one act (task 1004560): decide, by one of its options
@@ -41,6 +41,8 @@ function overrideMatter(r, mayDecide) {
41
41
  tally: null,
42
42
  href: `/task/${encodeURIComponent(r.task_id)}`,
43
43
  ref: { type: 'override_request', id: r.id },
44
+ question: 'Let this work through although it failed its check?',
45
+ context: `${r.requester_display_name || r.requester_login || 'A builder'} asks to let the task "${r.task_title || r.task_id}" through. Its automatic check or grade failed it, so it cannot land on its own. Approving treats the work as passed; holding it keeps it out until it is fixed.`,
44
46
  // the card's "more" (task 1004566): the reason GET /override-requests shows
45
47
  detail: r.rationale || null,
46
48
  };
@@ -60,6 +62,8 @@ function removalMatter(r) {
60
62
  tally: null,
61
63
  href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
62
64
  ref: { type: 'task', id: r.proposal_task_id },
65
+ question: 'Is this shipped work really gone?',
66
+ context: `A later change deleted files that earlier shipped work put there${r.goal_title ? `, in the goal "${r.goal_title}"` : ''}. Moves and rewrites can look like deletions, so a person decides: Remove means that work no longer counts as alive; Keep means it still does. Nothing is undone either way.`,
63
67
  // the card's "more" (task 1004566): the proposal's own text, from the same read
64
68
  detail: r.description || null,
65
69
  // POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
@@ -84,12 +88,31 @@ function uatActs(c, { force = false } = {}) {
84
88
  return acts;
85
89
  }
86
90
 
91
+ // What a Review card says (task 1005013): the promise in quotes, the goal it
92
+ // belongs to, that its work shipped, and what the sign-off closes. Pure.
93
+ function quoteOf(md) {
94
+ // the promise's own last full stop goes: the sentence around it supplies one
95
+ const t = String(md || '').replace(/[*_`#>]+/g, '').replace(/\s+/g, ' ').trim().replace(/\.+$/, '');
96
+ return t.length <= 220 ? t : t.slice(0, 219).replace(/\s+\S*$/, '') + '…';
97
+ }
98
+ function uatContext(c) {
99
+ const n = Number(c.shipped_total) || 0;
100
+ const goal = c.goal_title ? `the goal "${c.goal_title}"` : 'this version';
101
+ const built = n === 1 ? 'The one task built for it has shipped.' : n > 1 ? `All ${n} tasks built for it have shipped.` : 'Its work has shipped.';
102
+ const how = c.backend_only === true
103
+ ? 'It has nothing to see on screen, so check it works and sign it off.'
104
+ : "Try it on the live site, then record a short video of it working and sign it off on the goal's page.";
105
+ return `The promise: "${quoteOf(c.criterion_md)}". It is one of the done-when promises of ${goal}. ${built} ${how} That closes the promise; the goal is achieved once all its promises are closed.`;
106
+ }
107
+
87
108
  function uatMatter(c, opts) {
88
109
  return {
89
110
  id: c.id,
90
111
  rule: 'signoff',
91
112
  state: 'needs',
92
113
  title: c.criterion_md,
114
+ question: c.backend_only === true ? 'Is this promise kept? Check it and sign it off.' : 'Does this work on the live site now?',
115
+ context: uatContext(c),
93
116
  short: 'waiting for a tester',
94
117
  where: c.goal_title ? `review · ${c.goal_title}` : 'review · a promise to sign off',
95
118
  opened_at: null,
@@ -118,6 +141,9 @@ function joinRequestMatter(r, mine) {
118
141
  tally: null,
119
142
  href: r.goal_id ? `/goals#/goal/${encodeURIComponent(r.goal_id)}` : '/goals',
120
143
  ref: { type: 'goal_membership_request', id: r.id },
144
+ context: mine
145
+ ? `You asked to join the team of the goal "${r.goal_title || r.goal_id}". Its lead decides; once you are in, you can claim its tasks.`
146
+ : `${r.display_name || r.github_login || 'Someone'} asks to join the team of the goal "${r.goal_title || r.goal_id}". Members of a goal may claim its tasks; its lead decides who joins.`,
121
147
  };
122
148
  }
123
149
 
@@ -258,6 +258,8 @@ function accessRequestDocketMatter(r) {
258
258
  tally: null,
259
259
  href: '/watch',
260
260
  ref: { type: 'access_request', id: r.id },
261
+ question: `Let ${r.display_name || r.github_login} join this project?`,
262
+ context: `${r.display_name || r.github_login} (GitHub: ${r.github_login}) asked to join this project as a builder${r.vouched ? ', signed in through Bongos so the name is really theirs' : ''}. Admitting them lets them sign in and claim work; declining keeps them out.`,
261
263
  // the card's "more" (task 1004566): their note, as GET /access-requests shows it
262
264
  detail: r.note || null,
263
265
  };
@@ -56,6 +56,8 @@ function redteamDocketMatter(r) {
56
56
  tally: null,
57
57
  href: '/watch',
58
58
  ref: { type: 'vulnerability_report', id: r.id },
59
+ question: 'Is this security flaw real?',
60
+ context: `A security report: someone found what they say is a flaw in this project itself${r.target ? ` (in ${r.target})` : ''}${r.severity ? `, rated ${r.severity}` : ''}. "${r.title}". ${r.status === 'repro_attempted' ? 'Someone has tried to reproduce it. ' : ''}Confirm it if it is real, so it is fixed and the reporter earns the bounty; dispute it if it is not.`,
59
61
  // the card's "more" (task 1004566): what GET /security/reports shows this
60
62
  // same atom — what it targets and the report's description
61
63
  detail: [r.target ? `Target: ${r.target}` : '', r.description || ''].filter(Boolean).join('\n\n') || null,