@bongos/core 1.19.871 → 1.19.872

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 (101) hide show
  1. package/.bongos-core.json +163 -93
  2. package/.claude/skills/merge-mode/SKILL.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +5 -0
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  11. package/docs/api/openapi.json +61 -3
  12. package/docs/api-reference.md +7 -1
  13. package/docs/architecture.md +7 -7
  14. package/docs/branding-contract.md +1 -1
  15. package/docs/copy-inventory.md +190 -189
  16. package/docs/copy-registry.json +264 -255
  17. package/docs/design/hall-direction-v2.md +2 -2
  18. package/docs/module-api-changelog.md +2 -0
  19. package/docs/onboarding/diagrams/README.md +4 -4
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/onboarding/primer.md +1 -1
  22. package/docs/recipes/README.md +1 -1
  23. package/docs/recipes/bongos-cli-release.md +4 -4
  24. package/docs/recipes/core-release-pipeline.md +3 -3
  25. package/docs/recipes/instance-deploy.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +2 -2
  27. package/docs/recipes/search-index-ingestion.md +2 -2
  28. package/docs/recipes/upgrading-the-core.md +1 -1
  29. package/modules/autonomy/gauge.js +218 -0
  30. package/modules/dev-box/app/README.md +1 -1
  31. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  32. package/modules/hall-ui/public/blockers.js +138 -34
  33. package/modules/hall-ui/public/builders.js +2 -1
  34. package/modules/hall-ui/public/collab.html +2 -2
  35. package/modules/hall-ui/public/collab.js +6 -3
  36. package/modules/hall-ui/public/dom-utils.js +46 -5
  37. package/modules/hall-ui/public/goals-page.js +7 -3
  38. package/modules/hall-ui/public/goals-render.js +4 -3
  39. package/modules/hall-ui/public/hall-render.js +1 -1
  40. package/modules/hall-ui/public/people.js +3 -3
  41. package/modules/hall-ui/public/profile-charts.js +5 -2
  42. package/modules/hall-ui/public/profile.html +3 -3
  43. package/modules/hall-ui/public/profile.js +6 -6
  44. package/modules/hall-ui/public/settings-specialities.js +2 -2
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/settings.js +2 -2
  47. package/modules/hall-ui/public/shell.js +70 -0
  48. package/modules/hall-ui/public/style.css +31 -0
  49. package/modules/hall-ui/public/work.js +12 -14
  50. package/modules/provisioning/module.json +3 -3
  51. package/modules/public-landing/public/terms.html +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/scripts/gds/autobongos-loop.js +170 -0
  55. package/scripts/gds/autobongos-run.js +317 -0
  56. package/scripts/gds/autobongos-verify.js +377 -0
  57. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  58. package/scripts/gds/autonomy-gauge.js +84 -0
  59. package/scripts/gds/run-unit-tests.js +120 -2
  60. package/scripts/gds/sequence.js +3 -0
  61. package/scripts/gds/start.js +16 -10
  62. package/src/bongos/core-update.js +147 -0
  63. package/src/bongos/routes/core-update.js +49 -0
  64. package/src/bongos/routes.js +2 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/account_privacy_flags.mjs +3 -1
  67. package/tests/autobongos_loop.mjs +446 -0
  68. package/tests/autobongos_verify.mjs +325 -0
  69. package/tests/autonomy_gauge.mjs +344 -0
  70. package/tests/blockers_hall.mjs +20 -6
  71. package/tests/blockers_kit_pager.mjs +260 -0
  72. package/tests/blockers_page_ui.mjs +5 -4
  73. package/tests/box_ship_permissions.mjs +30 -0
  74. package/tests/core_update_banner.mjs +308 -0
  75. package/tests/eviction_policy.mjs +22 -1
  76. package/tests/git_guard.mjs +68 -0
  77. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  78. package/tests/goal_invite_consent.mjs +7 -0
  79. package/tests/goal_sort_staleness.mjs +6 -2
  80. package/tests/hall_board_world.mjs +4 -1
  81. package/tests/hall_builders_page_pass.mjs +7 -0
  82. package/tests/hall_landing_boot.mjs +7 -0
  83. package/tests/hall_profile_world.mjs +20 -2
  84. package/tests/hall_record_world.mjs +7 -0
  85. package/tests/hall_roster_world.mjs +2 -1
  86. package/tests/hall_studio_world.mjs +7 -0
  87. package/tests/hook_shell_tools.mjs +23 -1
  88. package/tests/idea_detail_page.mjs +7 -0
  89. package/tests/idea_spark_hall.mjs +7 -0
  90. package/tests/integration_parked_ratchet.mjs +79 -0
  91. package/tests/module_contributions.mjs +134 -8
  92. package/tests/people_directory_clip.mjs +7 -0
  93. package/tests/profile_activity.mjs +7 -0
  94. package/tests/profile_ui_cross_project.mjs +9 -1
  95. package/tests/provision_disconnect.mjs +7 -0
  96. package/tests/provision_restart.mjs +7 -0
  97. package/tests/provision_settings_apply.mjs +7 -0
  98. package/tests/sessions_kit_pager.mjs +7 -0
  99. package/tests/watch_applications_queue.mjs +7 -0
  100. package/tests/watch_roster_self_row.mjs +7 -0
  101. package/tests/watch_sealed_floor.mjs +7 -0
@@ -1,5 +1,5 @@
1
- // blockers.js — the Blockers page: the full open-blocker queue, and now (task
2
- // 1003456, goal 1000089) the write half — file, resolve, link.
1
+ // blockers.js — the Blockers page: the full open-blocker queue, the write half
2
+ // (task 1003456, goal 1000089), and server-offset paging (task 1003983).
3
3
  //
4
4
  // GET /blockers (modules/ideas/routes/blockers.js) is PUBLIC and had ZERO hall
5
5
  // consumers before task 1003455 — the only blocker a builder could ever see was
@@ -30,6 +30,21 @@
30
30
  // public, the roster/drachmae precedent: a page must not be gated tighter than
31
31
  // the API it fronts. A signed-out visitor sees the queue and the File button;
32
32
  // clicking it redirects to sign-in, same as every other hall write.
33
+ //
34
+ // SERVER-OFFSET PAGING (task 1003983): this used to fetch the whole queue at
35
+ // the max page size once and slice it client-side, which silently truncated
36
+ // past 200 open blockers and undercounted the headline pill from that same
37
+ // truncated array. It now asks the server for one page at a time via the
38
+ // kit's serverPager primitives (hall-ui/CLAUDE.md, the profile activity-log /
39
+ // session-ledger precedent), hand-wired rather than `makeLedger` because
40
+ // these rows are `.ov-row` `<li>`s, not a `<table>` — the same reason
41
+ // sessions.js wires them by hand, and this file keeps its two behaviours:
42
+ // every control dead while a fetch is in flight, and a failed fetch rolls
43
+ // the pager back to the last page that actually painted. `reload()` (fired
44
+ // after a file/resolve/link write, and by the overlay's
45
+ // `otb:blocker-resolved` event) re-fetches the CURRENT server page rather
46
+ // than the whole queue — the total and which rows belong on this page can
47
+ // both have moved.
33
48
  (function () {
34
49
  'use strict';
35
50
  var kit = window.OTBKit;
@@ -83,7 +98,6 @@
83
98
  };
84
99
  var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
85
100
 
86
- var all = [];
87
101
  // Which rank a reviewer action (Resolve / Link) needs — mirrors the server's
88
102
  // blocker.review floor (metic+archon), cosmetically only; the server holds
89
103
  // the actual gate regardless of what this variable says.
@@ -148,47 +162,136 @@
148
162
  }
149
163
 
150
164
  var pager = kit.makePager('blockers', 'hall-blockers-per-page', { defaultPerPage: 25 });
151
- var listEl = null;
152
-
153
- function rerender() {
154
- var slice = kit.paginate(all, pager);
155
- listEl.innerHTML = slice.length
156
- ? slice.map(rowHtml).join('')
157
- : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
158
- }
165
+ // `total`/`shown` are the server's own count of the whole open set and how
166
+ // many rows the last fetch actually returned — the headline pill and the
167
+ // pager bar both read off these, never off a held array's length.
168
+ // `page`/`perPage` are the last values that actually PAINTED: the kit moves
169
+ // the pager first and calls back second, so a failed fetch has to put it
170
+ // back to these, the sessions.js contract — otherwise the bar goes on
171
+ // claiming a page whose rows never arrived.
172
+ var state = { total: 0, shown: 0, page: pager.page, perPage: pager.perPage };
173
+ // Nothing has painted yet on the very first load, so a failure there has no
174
+ // prior page to roll back to or keep on screen — that one case still falls
175
+ // through to notice()'s full-body replacement.
176
+ var painted = false;
177
+ var loading = false;
178
+ // Two page clicks (or a slow first load racing a fast second one) can be in
179
+ // flight at once; only the newest may paint (the profile activity-log idiom).
180
+ var inFlight = 0;
181
+ // The rows currently on screen — kept ONLY so a purely local state change
182
+ // (a row's action form opening/closing, the viewer's rank arriving) can
183
+ // repaint without asking the server again. Never sliced or paged from here.
184
+ var lastRows = [];
159
185
 
160
- function reload() {
161
- return fetchBlockers().catch(function () { toast('Couldn’t refresh the queue — try reloading the page.'); });
186
+ function renderPager() {
187
+ var clamped = kit.renderServerPage(pager, state.total, state.shown);
188
+ // The per-page control has no bounds of its own, so it tracks `loading`
189
+ // directly both ways. Prev/Next re-enabling is left to the kit, which
190
+ // knows whether there is a page on either side — only the DISABLING past
191
+ // that is ours.
192
+ var perPage = document.getElementById(pager.prefix + '-per-page');
193
+ if (perPage) perPage.disabled = loading;
194
+ if (loading) {
195
+ var prev = document.getElementById(pager.prefix + '-prev');
196
+ var next = document.getElementById(pager.prefix + '-next');
197
+ if (prev) prev.disabled = true;
198
+ if (next) next.disabled = true;
199
+ }
200
+ return clamped;
162
201
  }
163
202
 
164
- function mountBlockers(rows) {
165
- all = Array.isArray(rows) ? rows : [];
166
- if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
203
+ function paintHeadline() {
204
+ if (countPill) { countPill.textContent = String(state.total); countPill.hidden = state.total === 0; }
167
205
  if (sub) {
168
- sub.textContent = all.length
169
- ? (all.length === 1 ? 'One thing' : all.length + ' things') + ' stuck on the owner — a credential, a '
206
+ sub.textContent = state.total
207
+ ? (state.total === 1 ? 'One thing' : state.total + ' things') + ' stuck on the owner — a credential, a '
170
208
  + 'purchase, a product call only they can make. Resolving one un-sticks every task waiting on it automatically.'
171
209
  : 'Nothing is stuck on the owner right now.';
172
210
  }
173
- body.innerHTML = '<ul class="ov-rows" id="blockers-list"></ul>' + kit.pagerBarHtml(pager.prefix, { label: 'blockers' });
174
- listEl = document.getElementById('blockers-list');
175
- kit.wirePager(pager, rerender);
176
- rerender();
177
211
  }
178
212
 
179
- function notice(msg) {
180
- body.innerHTML = '';
181
- body.className = 'note';
182
- body.textContent = msg;
213
+ function renderList(rows) {
214
+ var listEl = document.getElementById('blockers-list');
215
+ if (!listEl) return;
216
+ listEl.innerHTML = rows.length
217
+ ? rows.map(rowHtml).join('')
218
+ : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
183
219
  }
184
220
 
185
- function fetchBlockers() {
186
- return fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
221
+ // Repaint the rows already on screen — no server call. Used when only local
222
+ // UI state changed (a row's Resolve/Link form opened or closed, or the
223
+ // viewer's rank just arrived and rowHtml() needs to reconsider isReviewer()).
224
+ function rerender() { renderList(lastRows); }
225
+
226
+ // Fetch the page the pager currently points at. `reclamped` guards against a
227
+ // shrinking queue looping forever — one re-ask, never a spin.
228
+ function fetchPage(reclamped) {
229
+ var seq = ++inFlight;
230
+ loading = true;
231
+ renderPager();
232
+ var url = API + '/blockers?limit=' + encodeURIComponent(String(Number(pager.perPage) || 0))
233
+ + '&offset=' + encodeURIComponent(String(kit.pageOffset(pager)));
234
+ return fetch(url, { headers: { Accept: 'application/json' } })
187
235
  .then(function (r) {
188
236
  if (!r.ok) throw new Error('http ' + r.status);
189
237
  return r.json();
190
238
  })
191
- .then(function (data) { mountBlockers(data && data.blockers); });
239
+ .then(function (data) {
240
+ if (seq !== inFlight) return;
241
+ var rows = (data && Array.isArray(data.blockers)) ? data.blockers : [];
242
+ state.total = (data && data.page && Number.isFinite(data.page.total)) ? data.page.total : rows.length;
243
+ state.shown = rows.length;
244
+ lastRows = rows;
245
+ renderList(rows);
246
+ paintHeadline();
247
+ painted = true;
248
+ loading = false;
249
+ var clamped = renderPager();
250
+ // The page that just painted is now the one to roll back to if a
251
+ // LATER page-turn fails.
252
+ state.page = pager.page;
253
+ state.perPage = pager.perPage;
254
+ // The queue shrank under an open page (a blocker got resolved between
255
+ // loads) — the kit already pulled the page back into range, so go get
256
+ // the rows that page actually holds rather than leaving a range that
257
+ // disagrees with the list.
258
+ if (clamped && !reclamped) return fetchPage(true);
259
+ })
260
+ .catch(function () {
261
+ if (seq !== inFlight) return;
262
+ loading = false;
263
+ // Put the pager back to the last page that actually painted — the kit
264
+ // already moved it forward before this call ran, so left alone the
265
+ // bar would go on claiming a page whose rows never arrived.
266
+ pager.page = state.page;
267
+ pager.perPage = state.perPage;
268
+ if (!painted) {
269
+ notice('Couldn’t load the blocker queue right now.');
270
+ return;
271
+ }
272
+ renderPager();
273
+ if (window.OTB && typeof window.OTB.toast === 'function') {
274
+ window.OTB.toast('Could not load that page. Try again.');
275
+ }
276
+ });
277
+ }
278
+
279
+ // Re-fetch the CURRENT server page — after a file/resolve/link write (the
280
+ // total and which rows belong on this page can both have moved) and on the
281
+ // overlay's `otb:blocker-resolved` event.
282
+ function reload() {
283
+ return fetchPage().catch(function () { toast('Couldn’t refresh the queue — try reloading the page.'); });
284
+ }
285
+
286
+ function mountShell() {
287
+ body.innerHTML = '<ul class="ov-rows" id="blockers-list"></ul>' + kit.pagerBarHtml(pager.prefix, { label: 'blockers' });
288
+ kit.wirePager(pager, function () { fetchPage(); });
289
+ }
290
+
291
+ function notice(msg) {
292
+ body.innerHTML = '';
293
+ body.className = 'note';
294
+ body.textContent = msg;
192
295
  }
193
296
 
194
297
  // ---- filing (any signed-in builder) ----------------------------------------
@@ -330,6 +433,9 @@
330
433
  } catch (_) { /* signed out or offline — viewer stays { rank: null }, isReviewer() false */ }
331
434
  }
332
435
 
436
+ mountShell();
437
+ fetchPage();
438
+
333
439
  loadViewer().then(function () {
334
440
  if (fileArea) { fileArea.innerHTML = fileFormHtml(); wireFileForm(); }
335
441
  // Boot race: GET /me and GET /blockers fire together, and rowHtml() reads
@@ -338,14 +444,12 @@
338
444
  // with isReviewer() still false and nothing re-draws it once the real
339
445
  // rank lands — a Metic/Archon would see no Resolve/Link buttons until an
340
446
  // unrelated re-render (a pager click, a file, an otb:blocker-resolved).
341
- // Re-render now that the viewer is known, but only if the list actually
342
- // exists yet (fetchBlockers may still be in flight — it will render
447
+ // Re-render now that the viewer is known, but only if a page has actually
448
+ // painted yet (fetchPage may still be in flight — it will render
343
449
  // correctly on its own the moment it resolves).
344
- if (listEl) rerender();
450
+ if (painted) rerender();
345
451
  });
346
452
 
347
- fetchBlockers().catch(function () { notice('Couldn’t load the blocker queue right now.'); });
348
-
349
453
  // A row's title opens the #/blocker/N overlay ON TOP of this page
350
454
  // (task-detail.js), whose own Resolve panel fires this — refresh the queue
351
455
  // in place rather than leaving a resolved row sitting there.
@@ -457,7 +457,8 @@
457
457
  }
458
458
  if (resp.onboarding) renderOnboarding(resp.onboarding, lastMeState && lastMeState.discord);
459
459
  if (resp.builder) renderProfile(resp.builder);
460
- toast(selected.length ? `Craft: ${selected.join(', ')}.` : 'Craft cleared.');
460
+ const axis = window.OTB.roleAxisLabel();
461
+ toast(selected.length ? `${axis}: ${selected.map((d) => window.OTB.roleLabel(d)).join(', ')}.` : `${axis} cleared.`);
461
462
  } catch (err) {
462
463
  if (err && err.message === 'redirecting to auth') return;
463
464
  console.error('[builders] pick discipline failed', err);
@@ -43,7 +43,7 @@
43
43
  </div>
44
44
  <ul class="ov-rows" id="cb-for-me"></ul>
45
45
  <div class="empty-state cb-empty" id="cb-for-me-empty" hidden>
46
- <p>Nobody is waiting on you. Requests reach you by craft —
46
+ <p>Nobody is waiting on you. Requests reach you by role —
47
47
  <a href="/builders/settings">set yours in Settings</a>.</p>
48
48
  </div>
49
49
  <button class="btn-ghost cb-more" id="cb-for-me-more" type="button" aria-expanded="false" aria-controls="cb-for-me" hidden></button>
@@ -77,7 +77,7 @@
77
77
  changes, it does not gain a destination. -->
78
78
  <section class="scroll cb-block" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
79
79
  <h2 class="scroll__h" id="cb-mingle-h">Introductions</h2>
80
- <p class="scroll__lede" id="cb-mingle-note">Mingle pairs you with someone from another craft. Saying no costs nothing.</p>
80
+ <p class="scroll__lede" id="cb-mingle-note">Mingle pairs you with someone from another role. Saying no costs nothing.</p>
81
81
  <ul class="ov-rows" id="cb-mingle"></ul>
82
82
  <div class="empty-state cb-empty" id="cb-mingle-empty" hidden>
83
83
  <p>No introductions right now. One appears here when a pairing round runs.</p>
@@ -329,9 +329,12 @@
329
329
 
330
330
  const sub = $('#cb-sub');
331
331
  if (sub) {
332
+ // task 1003936: the axis word comes from OTB.roleAxisLabel(), never typed.
333
+ // The VALUES are named through roleLabel() for the same reason the filters are.
334
+ const axis = window.OTB.roleAxisLabel().toLowerCase();
332
335
  sub.textContent = crafts.length
333
- ? `Builders asking builders. Asks to ${crafts.join(', ')}s, and to you by name, land in your block.`
334
- : 'Builders asking builders. With no craft set, only asks addressed to you by name land in your block.';
336
+ ? `Builders asking builders. Asks to ${crafts.map((c) => `${window.OTB.roleLabel(c)}s`).join(', ')}, and to you by name, land in your block.`
337
+ : `Builders asking builders. With no ${axis} set, only asks addressed to you by name land in your block.`;
335
338
  }
336
339
 
337
340
  // The social half. Best-effort and LAST: an introductions read that fails
@@ -366,7 +369,7 @@
366
369
  ${faceHtml(face)}
367
370
  <div class="ov-row__main">
368
371
  <a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
369
- <p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across crafts.</p>
372
+ <p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across roles.</p>
370
373
  </div>
371
374
  <div class="ov-row__right">
372
375
  <div class="ov-row__actions">
@@ -155,21 +155,60 @@
155
155
  }
156
156
 
157
157
  // How a kind is named in a picker — 'blocker-resolution' → 'Blocker resolution'.
158
- // The same shape disciplineLabel takes below, and it reproduces every label the
158
+ // The same shape roleLabel takes below, and it reproduces every label the
159
159
  // hand-written <option> list in ideas.html carried, so no copy changes.
160
160
  function kindLabel(k) {
161
161
  const s = String(k || '').replace(/-/g, ' ');
162
162
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
163
163
  }
164
164
 
165
- // How a craft is named in a picker. An acronym stays upper-case ('ui' → 'UI');
166
- // anything else is Title Case, so a craft a module adds later needs no new entry.
167
- function disciplineLabel(d) {
165
+ // THE ONE USER-FACING WORD FOR THE tasks.discipline AXIS (task 1003936).
166
+ // Before this the hall shipped four words for one axis: Discipline on the Tasks
167
+ // board, Discipline AND Craft on the Goals page 77 lines apart, Craft in the
168
+ // Directory and Settings, craft in Collab, Type/lane on the builder-start card.
169
+ // A label repeated in six files is free to drift back, so the word lives HERE and
170
+ // every surface reads it — the same argument fillKindFilter above was written on.
171
+ //
172
+ // The word is ROLE, per ADR 0174 Decision 5 ("role" means a craft — and nothing
173
+ // else), confirmed by the owner on 2026-09-18 as the label that ships. Renaming
174
+ // the COLUMN or the API field is goal 1000066’s job and deliberately not done here.
175
+ const ROLE_AXIS_LABEL = 'Role';
176
+ function roleAxisLabel() { return ROLE_AXIS_LABEL; }
177
+
178
+ // A task with no role carries the DB default 'unclassified'. That is a word a
179
+ // column chose, not one a person would say (docs/voice.md V2), so a picker shows
180
+ // "No role yet" and a row tag renders NOTHING — roleIsUnset() is how a caller asks.
181
+ const ROLE_UNSET = 'unclassified';
182
+ function roleIsUnset(d) { return !d || String(d) === ROLE_UNSET; }
183
+
184
+ // NOT ESCAPE-SAFE: this returns a capitalised copy of its input, so a caller
185
+ // rendering it into markup must escapeHtml() the result, as every call site does.
186
+ // How a role is named wherever one is shown. An acronym stays upper-case
187
+ // ('ui' → 'UI'); anything else is sentence case, so a role a module adds later
188
+ // needs no new entry. Was disciplineLabel() — renamed with the axis.
189
+ function roleLabel(d) {
168
190
  const s = String(d || '');
169
191
  if (!s) return '';
192
+ if (s === ROLE_UNSET) return 'No role yet';
170
193
  return s === 'ui' ? 'UI' : s.charAt(0).toUpperCase() + s.slice(1);
171
194
  }
172
195
 
196
+ // The option list for any Role <select>, so no surface types the values or the
197
+ // labels again. Prefers the roles the SERVER serves (the same list
198
+ // db.updateBuilderDisciplines validates a PATCH against), and falls back to the
199
+ // four this tree ships when an older server sent none — never to an empty picker.
200
+ // `unset: true` appends the "No role yet" bucket for the boards that filter on it.
201
+ function roleOptions({ any = '— any —', unset = false } = {}) {
202
+ // disciplines() is the ONE reader of the served list, and it carries this
203
+ // file's only fallback enumeration — a second one here would be the very
204
+ // duplication this vocabulary exists to remove.
205
+ const list = disciplines();
206
+ const opts = [{ value: '', label: any }];
207
+ for (const d of list) opts.push({ value: d, label: roleLabel(d) });
208
+ if (unset) opts.push({ value: ROLE_UNSET, label: roleLabel(ROLE_UNSET) });
209
+ return opts;
210
+ }
211
+
173
212
  // Module→core settings-panel contribution seam (BV1.R42, ADR 0083) — the
174
213
  // browser twin of the server's 'profile.fields' seam (src/module-seams.js). A
175
214
  // module that has left core registers a Settings panel from its own client
@@ -365,7 +404,9 @@
365
404
  }
366
405
 
367
406
  window.OTB = {
368
- escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines, disciplineLabel,
407
+ escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
408
+ // task 1003936 — ONE word for the role axis, all six surfaces
409
+ roleAxisLabel, roleLabel, roleOptions, roleIsUnset, ROLE_UNSET,
369
410
  fillKindFilter, kindLabel, // task 1003453 — one Kind filler, all three surfaces
370
411
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
371
412
  contributeSettingsPanel, settingsPanels,
@@ -963,8 +963,11 @@
963
963
  // disciplines a goal's tasks carry, derived in loadRollups. Staleness
964
964
  // buckets are the shared STALE_CHOICES from task-lib.js so this page and
965
965
  // the Tasks board offer the same lenses.
966
- { id: 'f-goal-role', label: 'Craft', options: [ // ADR 0174: "role" means a craft
967
- { value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui'] },
966
+ // task 1003936 — this bar filters GOALS, the overflow bar below filters the
967
+ // TASKS inside one. Both are the same axis, so both take the word from
968
+ // OTB.roleAxisLabel() (ADR 0174: "role" is the CRAFT axis, never a goal
969
+ // membership, which stays a membership_kind — ADR 0177).
970
+ { id: 'f-goal-role', label: window.OTB.roleAxisLabel(), options: window.OTB.roleOptions() }, // ADR 0174
968
971
  { id: 'f-goal-stale', label: 'Age', options: window.OTBTaskLib.STALE_CHOICES },
969
972
  ],
970
973
  onChange: onListFilterChange,
@@ -1040,7 +1043,8 @@
1040
1043
  // here — this copy had gone stale against the DB CHECK exactly as the
1041
1044
  // board's had, so 'verify' work could not be filtered for on either.
1042
1045
  { id: 'f-goal-kind', label: 'Kind', options: [{ value: '', label: '— any —' }] },
1043
- { id: 'f-goal-discipline', label: 'Discipline', options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'] },
1046
+ // ADR 0174: the craft axis again, scoped to the tasks inside one goal.
1047
+ { id: 'f-goal-discipline', label: `Task ${window.OTB.roleAxisLabel().toLowerCase()}`, options: window.OTB.roleOptions({ unset: true }) }, // ADR 0174
1044
1048
  { id: 'f-goal-newcomer', label: 'Newcomer', options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }] },
1045
1049
  { id: 'f-goal-fit', label: 'Run mode', options: [{ value: '', label: '— any —' },
1046
1050
  { value: 'parallel', label: 'parallel-safe only' },
@@ -318,8 +318,9 @@
318
318
  const cpm = creditsPerMinute(t);
319
319
  const cpmText = Number.isFinite(cpm) ? `${cpm.toFixed(2)} ${curLabel()}/min` : `— ${curLabel()}/min`;
320
320
  const minutesText = t.est_minutes ? `${t.est_minutes}m` : '—';
321
- const discipline = t.discipline || 'unclassified';
322
- const disciplineTag = discipline !== 'unclassified' ? `<span class="task__tag">${escapeHtml(discipline)}</span>` : '';
321
+ const disciplineTag = window.OTB.roleIsUnset(t.discipline)
322
+ ? ''
323
+ : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`;
323
324
  const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
324
325
  const rankTag = rankTagHtml(t.requires_rank);
325
326
  const touches = Array.isArray(t.touches) ? t.touches : [];
@@ -502,7 +503,7 @@
502
503
  let list = state.work.todo.filter((t) => {
503
504
  if (!matchesTodoSearch(t, sQuery)) return false;
504
505
  if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
505
- if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
506
+ if (dFilter && (t.discipline || window.OTB.ROLE_UNSET) !== dFilter) return false;
506
507
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
507
508
  return true;
508
509
  });
@@ -1013,7 +1013,7 @@
1013
1013
  // a module-contributed craft (ui-design → ui) never appeared here at all.
1014
1014
  action = `
1015
1015
  <div class="onboarding-item__action onboarding-item__disc-pick">
1016
- ${OTB.disciplines().map((d) => discBtn(d, OTB.disciplineLabel(d))).join('')}
1016
+ ${OTB.disciplines().map((d) => discBtn(d, OTB.roleLabel(d))).join('')}
1017
1017
  </div>`;
1018
1018
  } else if (s.id === 'ship_three') {
1019
1019
  action = `
@@ -196,13 +196,13 @@
196
196
  crafts.sort();
197
197
  kit.makeFilterBar({
198
198
  mount: document.getElementById('people-filters'),
199
- search: { id: 'people-q', label: 'Search', placeholder: 'name or craft' },
199
+ search: { id: 'people-q', label: 'Search', placeholder: `name or ${window.OTB.roleAxisLabel().toLowerCase()}` },
200
200
  primary: [
201
201
  { id: 'people-sort', label: 'Sort', options: [
202
202
  { value: '', label: 'track record' }, { value: 'ships', label: 'most ships' },
203
203
  { value: 'credits', label: 'most ' + currencyLabel().toLowerCase() }, { value: 'karma', label: 'most karma' },
204
204
  { value: 'projects', label: 'most projects' }, { value: 'recent', label: 'most recently active' }] },
205
- { id: 'people-craft', label: 'Craft', options: [{ value: '', label: '— any —' }].concat(crafts) },
205
+ { id: 'people-craft', label: window.OTB.roleAxisLabel(), options: [{ value: '', label: '— any —' }].concat(crafts.map((c) => ({ value: c, label: window.OTB.roleLabel(c) }))) },
206
206
  ],
207
207
  onChange: function (vals) {
208
208
  query = String(vals['people-q'] || '').trim().toLowerCase();
@@ -217,7 +217,7 @@
217
217
  ledger = kit.makeLedger({
218
218
  mount: body,
219
219
  columns: ['Builder', { label: 'Projects', class: 'ledger__num' }, { label: 'Ships', class: 'ledger__num' },
220
- { label: currencyLabel(), class: 'ledger__num' }, { label: 'Karma', class: 'ledger__num' }, 'Crafts', 'Last active'],
220
+ { label: currencyLabel(), class: 'ledger__num' }, { label: 'Karma', class: 'ledger__num' }, `${window.OTB.roleAxisLabel()}s`, 'Last active'],
221
221
  rowHtml: rowHtml,
222
222
  // Reached only when the directory HAS builders and the lenses excluded them
223
223
  // all — the empty-API case returns above with its own explanation.
@@ -128,7 +128,10 @@
128
128
  function craftMix(rows, max = MIX_MAX) {
129
129
  const counts = new Map();
130
130
  for (const r of rows || []) {
131
- const k = (r && r.discipline) || 'unclassified';
131
+ // task 1003936: the bucket KEY stays the stored value, but what a person
132
+ // reads is OTB.roleLabel() — an unset role charted as "unclassified" was the
133
+ // database's word on a page (docs/voice.md V2).
134
+ const k = window.OTB.roleLabel((r && r.discipline) || window.OTB.ROLE_UNSET);
132
135
  counts.set(k, (counts.get(k) || 0) + 1);
133
136
  }
134
137
  const sorted = [...counts.entries()].map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n || a.label.localeCompare(b.label));
@@ -462,7 +465,7 @@
462
465
  : `${fmtNum(paidFull.length ? paidFull[paidFull.length - 1].value : 0)} paid, from the credits ledger · ${span}`,
463
466
  }),
464
467
  mixChart({
465
- id: 'chart-mix', title: 'Works by craft', mix,
468
+ id: 'chart-mix', title: `Works by ${window.OTB.roleAxisLabel().toLowerCase()}`, mix,
466
469
  footer: `${fmtNum(inWindow.length)} work${inWindow.length === 1 ? '' : 's'} · ${span}`,
467
470
  }),
468
471
  ];
@@ -105,7 +105,7 @@
105
105
  craft at a time, with goals and the idea trail. -->
106
106
  <section class="scroll" id="works-scroll" aria-labelledby="works-h" data-group="works">
107
107
  <h2 id="works-h" class="scroll__h">Works</h2>
108
- <p class="scroll__lede" id="works-lede">Everything this builder has shipped. Seen one craft at a time, with goals and ideas, under <a href="#activity">activity</a>.</p>
108
+ <p class="scroll__lede" id="works-lede">Everything this builder has shipped. Seen one role at a time, with goals and ideas, under <a href="#activity">activity</a>.</p>
109
109
  <table class="ledger" id="works-table">
110
110
  <thead>
111
111
  <tr>
@@ -130,8 +130,8 @@
130
130
  the ideator view) every idea they filed with what became of it.
131
131
  All three ledgers page server-side; profile.js fills them. -->
132
132
  <section class="scroll" id="activity-scroll" aria-labelledby="activity-h" data-group="activity">
133
- <h2 id="activity-h" class="scroll__h">Activity, by craft</h2>
134
- <p class="scroll__lede" id="activity-lede">What this builder has done, seen one craft at a time.</p>
133
+ <h2 id="activity-h" class="scroll__h">Activity, by role</h2>
134
+ <p class="scroll__lede" id="activity-lede">What this builder has done, seen one role at a time.</p>
135
135
  <div id="activity-tabs"></div>
136
136
  <div id="activity-body">
137
137
  <div class="profile__placeholder" id="activity-placeholder">…</div>
@@ -305,7 +305,7 @@
305
305
  `</div>`;
306
306
  const discs = Array.isArray(cp.disciplines) ? cp.disciplines.filter((d) => typeof d === 'string') : [];
307
307
  const metaBits = [];
308
- if (discs.length) metaBits.push(`Disciplines: ${discs.join(', ')}`);
308
+ if (discs.length) metaBits.push(`${window.OTB.roleAxisLabel()}s: ${discs.map((d) => window.OTB.roleLabel(d)).join(', ')}`);
309
309
  if (cp.last_active) metaBits.push(`Last active ${fmtDate(cp.last_active)}`);
310
310
  const meta = metaBits.length ? `<p class="scroll__lede">${escapeHtml(metaBits.join(' · '))}</p>` : '';
311
311
  const named = Array.isArray(cp.projects) ? cp.projects : [];
@@ -459,10 +459,10 @@
459
459
  columns: [
460
460
  { label: '#', class: 'ledger__num' },
461
461
  'Work',
462
- { label: 'Craft' },
462
+ { label: window.OTB.roleAxisLabel() },
463
463
  { label: 'Shipped', class: 'ledger__date' },
464
464
  ],
465
- emptyMsg: 'No completed works in this craft.',
465
+ emptyMsg: `No completed works in this ${window.OTB.roleAxisLabel().toLowerCase()}.`,
466
466
  fetchPage: async (offset, limit) => {
467
467
  const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
468
468
  + `?section=tasks&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
@@ -477,7 +477,7 @@
477
477
  return `<tr>
478
478
  <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
479
479
  <td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
480
- <td><span class="task__tag">${escapeHtml(t.discipline || 'unclassified')}</span></td>
480
+ <td>${window.OTB.roleIsUnset(t.discipline) ? '' : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`}</td>
481
481
  <td class="ledger__date">${fmtDate(t.shipped_at)}</td>
482
482
  </tr>`;
483
483
  },
@@ -489,10 +489,10 @@
489
489
  columns: [
490
490
  { label: '#', class: 'ledger__num' },
491
491
  'Goal',
492
- 'Crafts',
492
+ `${window.OTB.roleAxisLabel()}s`,
493
493
  'Progress',
494
494
  ],
495
- emptyMsg: 'No goals created in this craft.',
495
+ emptyMsg: `No goals created in this ${window.OTB.roleAxisLabel().toLowerCase()}.`,
496
496
  fetchPage: async (offset, limit) => {
497
497
  const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
498
498
  + `?section=goals&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
@@ -69,7 +69,7 @@
69
69
  <details class="speciality-contract"${theirs ? ' open' : ''}>
70
70
  <summary>${theirs ? 'What this adds to your sessions — written by someone else' : 'What this adds to your sessions'}</summary>
71
71
  <pre class="speciality-contract__body">${escapeHtml(s.contract_md)}</pre>
72
- ${theirs ? '<p class="prow__hint">You did not write this. Read it before using the speciality — it reaches every session where you hold work in that craft, and it is passed to Claude as this author\'s stated preferences, not as instructions it must obey.</p>' : ''}
72
+ ${theirs ? '<p class="prow__hint">You did not write this. Read it before using the speciality — it reaches every session where you hold work in that role, and it is passed to Claude as this author\'s stated preferences, not as instructions it must obey.</p>' : ''}
73
73
  </details>`;
74
74
  }
75
75
 
@@ -253,7 +253,7 @@
253
253
  const name = create.elements.name.value.trim();
254
254
  const discipline = create.elements.discipline.value;
255
255
  if (!name) { statusLine('Give it a name.'); return; }
256
- if (!discipline) { statusLine('Pick which craft it sits under.'); return; }
256
+ if (!discipline) { statusLine(`Pick which ${window.OTB.roleAxisLabel().toLowerCase()} it sits under.`); return; }
257
257
  try {
258
258
  await call('POST', '/specialities', {
259
259
  name,
@@ -123,7 +123,7 @@
123
123
 
124
124
  <!-- CRAFT — discipline preferences (GET /me, PATCH /me/disciplines). -->
125
125
  <section class="scroll" id="craft-scroll" aria-labelledby="craft-h" data-group="account" hidden>
126
- <h2 id="craft-h" class="scroll__h">Preferred disciplines</h2>
126
+ <h2 id="craft-h" class="scroll__h">Preferred roles</h2>
127
127
  <p class="scroll__lede">
128
128
  The kinds of work you prefer. Task suggestions lean toward your
129
129
  choices — pick one, several, or none.
@@ -278,9 +278,9 @@
278
278
  <section class="scroll" id="specialities-scroll" aria-labelledby="specialities-h" data-group="preferences" hidden>
279
279
  <h2 id="specialities-h" class="scroll__h">Your specialities</h2>
280
280
  <p class="scroll__lede">
281
- A craft says how you work. A speciality says what you work on — and how you,
281
+ A role says how you work. A speciality says what you work on — and how you,
282
282
  as someone who knows the subject, want to be engaged inside it. Whichever one
283
- you are using reaches every session where you hold a claim in that craft.
283
+ you are using reaches every session where you hold a claim in that role.
284
284
  Train it by adding things you have written; it also picks things up on its own
285
285
  as you work.
286
286
  </p>
@@ -294,7 +294,7 @@
294
294
  <input type="text" name="name" maxlength="80" required placeholder="Economics" />
295
295
  </label>
296
296
  <label class="speciality-field">
297
- <span>Craft it sits under</span>
297
+ <span>Role it sits under</span>
298
298
  <select name="discipline" required>
299
299
  <option value="">Pick one…</option>
300
300
  <option value="engineer">Engineer</option>
@@ -555,7 +555,7 @@
555
555
  const follows = !data || data.recruiter_discoverable == null;
556
556
  const grant = data && data.hide_stats
557
557
  ? 'Because your cross-project record is hidden, they will see your name and your picture, and nothing else.'
558
- : 'They will see your name, your picture, your disciplines, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on.';
558
+ : 'They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on.';
559
559
  // The surprising half, and only where it applies: a private profile answers
560
560
  // "not found" to every visitor, and this one switch is the exception.
561
561
  const despitePrivate = isPublic ? ''
@@ -684,7 +684,7 @@
684
684
  return (
685
685
  `<label class="craft-chip" data-disc="${escapeHtml(d)}" data-on="${on ? '1' : '0'}">` +
686
686
  `<input type="checkbox" class="craft-pref" value="${escapeHtml(d)}"${on ? ' checked' : ''}>` +
687
- `${escapeHtml(window.OTB.disciplineLabel(d))}</label>`
687
+ `${escapeHtml(window.OTB.roleLabel(d))}</label>`
688
688
  );
689
689
  }).join('');
690
690
  }