@bongos/core 1.19.870 → 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 (102) hide show
  1. package/.bongos-core.json +164 -94
  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 +4 -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/goal_subtitle.mjs +48 -4
  81. package/tests/hall_board_world.mjs +4 -1
  82. package/tests/hall_builders_page_pass.mjs +7 -0
  83. package/tests/hall_landing_boot.mjs +7 -0
  84. package/tests/hall_profile_world.mjs +20 -2
  85. package/tests/hall_record_world.mjs +7 -0
  86. package/tests/hall_roster_world.mjs +2 -1
  87. package/tests/hall_studio_world.mjs +7 -0
  88. package/tests/hook_shell_tools.mjs +23 -1
  89. package/tests/idea_detail_page.mjs +7 -0
  90. package/tests/idea_spark_hall.mjs +7 -0
  91. package/tests/integration_parked_ratchet.mjs +79 -0
  92. package/tests/module_contributions.mjs +134 -8
  93. package/tests/people_directory_clip.mjs +7 -0
  94. package/tests/profile_activity.mjs +7 -0
  95. package/tests/profile_ui_cross_project.mjs +9 -1
  96. package/tests/provision_disconnect.mjs +7 -0
  97. package/tests/provision_restart.mjs +7 -0
  98. package/tests/provision_settings_apply.mjs +7 -0
  99. package/tests/sessions_kit_pager.mjs +7 -0
  100. package/tests/watch_applications_queue.mjs +7 -0
  101. package/tests/watch_roster_self_row.mjs +7 -0
  102. package/tests/watch_sealed_floor.mjs +7 -0
@@ -231,7 +231,8 @@ test('the directory has a search, a sort and a craft lens on the kit bar, and pa
231
231
  assert.match(src, /kit\.makeFilterBar\(\{/);
232
232
  assert.match(src, /search: \{ id: 'people-q'/);
233
233
  assert.match(src, /\{ id: 'people-sort', label: 'Sort'/);
234
- assert.match(src, /\{ id: 'people-craft', label: 'Craft'/);
234
+ // task 1003936: the axis label is read from OTB.roleAxisLabel(), never typed.
235
+ assert.match(src, /\{ id: 'people-craft', label: window\.OTB\.roleAxisLabel\(\)/);
235
236
  assert.match(src, /kit\.makePager\('people'/);
236
237
  assert.match(src, /kit\.makeLedger\(\{/);
237
238
  assert.doesNotMatch(src, /createElement\('table'\)/, 'no hand-built table');
@@ -259,6 +259,13 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
259
259
  const window = {
260
260
  BongosClient: { createClient: () => ({ request }) },
261
261
  OTB: {
262
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
263
+ // the same reason categoryChip is: an undefined helper throws mid-render.
264
+ roleAxisLabel: () => 'Role',
265
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
266
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
267
+ roleIsUnset: (d) => !d || d === 'unclassified',
268
+ ROLE_UNSET: 'unclassified',
262
269
  escapeHtml: (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
263
270
  $: (sel) => el(String(sel).replace(/^#/, '')),
264
271
  toast: () => {},
@@ -26,7 +26,7 @@ import { fileURLToPath } from 'node:url';
26
26
 
27
27
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
28
  const require = createRequire(import.meta.url);
29
- const { SHELL_TOOLS, MATCHER, isShellTool } = require(
29
+ const { SHELL_TOOLS, MATCHER, isShellTool, escapesWithBacktick } = require(
30
30
  path.join(ROOT, '.claude', 'hooks', 'shell-tools.js'),
31
31
  );
32
32
 
@@ -113,3 +113,25 @@ test('isShellTool admits every listed tool and nothing else, and never throws',
113
113
  assert.equal(isShellTool(junk), false);
114
114
  }
115
115
  });
116
+
117
+ // task 1004028 — the shells differ, and a guard that reads a command literally reads
118
+ // the wrong thing on the one whose escape character vanishes before its parser.
119
+ test('escapesWithBacktick names PowerShell and not Bash, and never throws', () => {
120
+ assert.equal(escapesWithBacktick('PowerShell'), true);
121
+ assert.equal(escapesWithBacktick('Bash'), false, 'a backtick opens a command substitution in Bash');
122
+ for (const other of ['powershell', 'Write', '']) assert.equal(escapesWithBacktick(other), false, other);
123
+ for (const junk of [undefined, null, 0, {}, []]) assert.equal(escapesWithBacktick(junk), false);
124
+ });
125
+
126
+ // A tool whose escape rules a guard normalizes must be a tool a guard is reached on
127
+ // at all — otherwise the entry is inert and reads as coverage that does not exist.
128
+ test('every backtick-escaping tool is itself a shell tool', () => {
129
+ for (const tool of SHELL_TOOLS.filter((t) => escapesWithBacktick(t))) assert.ok(isShellTool(tool));
130
+ const src = fs.readFileSync(path.join(ROOT, '.claude', 'hooks', 'shell-tools.js'), 'utf8');
131
+ const listed = (src.match(/const BACKTICK_ESCAPE_TOOLS = \[([^\]]*)\]/) || [, ''])[1]
132
+ .split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
133
+ assert.ok(listed.length, 'BACKTICK_ESCAPE_TOOLS could not be read');
134
+ for (const tool of listed) {
135
+ assert.ok(SHELL_TOOLS.includes(tool), `BACKTICK_ESCAPE_TOOLS names "${tool}", which is not a shell tool`);
136
+ }
137
+ });
@@ -91,6 +91,13 @@ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus =
91
91
  __BRANDING__: {}, __MODULES__: { enabled: {} },
92
92
  BongosClient: { createClient: () => client },
93
93
  OTB: {
94
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
95
+ // the same reason categoryChip is: an undefined helper throws mid-render.
96
+ roleAxisLabel: () => 'Role',
97
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
98
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
99
+ roleIsUnset: (d) => !d || d === 'unclassified',
100
+ ROLE_UNSET: 'unclassified',
94
101
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
95
102
  $: (sel) => documentObj.querySelector(sel),
96
103
  fmtDate: (iso) => String(iso).slice(0, 10),
@@ -126,6 +126,13 @@ function boot({
126
126
  __BRANDING__: {}, __MODULES__: { enabled: {} },
127
127
  BongosClient: { createClient: () => client },
128
128
  OTB: {
129
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
130
+ // the same reason categoryChip is: an undefined helper throws mid-render.
131
+ roleAxisLabel: () => 'Role',
132
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
133
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
134
+ roleIsUnset: (d) => !d || d === 'unclassified',
135
+ ROLE_UNSET: 'unclassified',
129
136
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
130
137
  $: (sel) => documentObj.querySelector(sel),
131
138
  toast: (m) => toasts.push(m),
@@ -0,0 +1,79 @@
1
+ // tests/integration_parked_ratchet.mjs — the DB-free half of INTEGRATION_PARKED's
2
+ // guarantees (task 1004034). Sibling of tests/known_failing_ratchet.mjs, which does
3
+ // the same duty for the DB-free lane's KNOWN_FAILING.
4
+ //
5
+ // THE SPLIT, AND WHY IT EXISTS. INTEGRATION_PARKED holds integration tests that fail
6
+ // and are excluded from the lane so it can gate the ones that pass. Its central
7
+ // promise — "a parked test that starts passing reds the lane until its entry goes" —
8
+ // can only be checked WITH a database, because a parked integration test run without
9
+ // one fails for want of the database and would report "still failing" whether or not
10
+ // anybody had fixed it. That half therefore lives in
11
+ // `run-unit-tests.js --integration-parked`, which the integration workflow runs as a
12
+ // blocking step.
13
+ //
14
+ // What is left is everything that does NOT need a database, and it is the half that
15
+ // rots quietest: an entry naming a file nobody kept, an entry for a test the lane was
16
+ // never going to run anyway, an entry double-listed in KNOWN_FAILING so two
17
+ // mechanisms disagree about who owns it, and the list quietly growing. None of those
18
+ // shows up as a red test anywhere else — the lane just silently gates less.
19
+ //
20
+ // DB-free, no network. Run: node tests/integration_parked_ratchet.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { test } from 'node:test';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { createRequire } from 'node:module';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
31
+ const { INTEGRATION, INTEGRATION_PARKED, KNOWN_FAILING } = require('../scripts/gds/run-unit-tests.js');
32
+
33
+ // The ceiling. Twenty-three is what the integration lane's first ever run surfaced
34
+ // (task 1000862) — every one a test nothing had executed before, not a regression.
35
+ // Lowering it as they are fixed is the point; raising it means a NEW failure was
36
+ // parked instead of fixed, which is a deliberate, reviewable act.
37
+ //
38
+ // RAISED 23 → 24 by task 1004042, and this is that act with its reason attached:
39
+ // acclaim_feed_evidence_db is not a new failure, it is one of the original set that
40
+ // was held back because it happened to PASS in the CI run the list was read from.
41
+ // Two later readings — 2/2 locally on a clean database, and the first CI run after
42
+ // the parking landed — showed the pass was the anomaly. No new breakage was parked
43
+ // here; one straggler joined the cohort it always belonged to.
44
+ const MAX_PARKED = 24;
45
+
46
+ test('every parked entry names a test file that exists', () => {
47
+ const ghosts = [...INTEGRATION_PARKED]
48
+ .filter((name) => !fs.existsSync(path.join(TESTS_DIR, `${name}.mjs`)))
49
+ .sort();
50
+ assert.deepEqual(ghosts, [],
51
+ 'these are parked but have no test file. An entry that outlives its test silently '
52
+ + 'shrinks what the lane gates and can never be un-parked — delete the entry, or '
53
+ + 'restore the file.');
54
+ });
55
+
56
+ test('every parked entry is one the integration lane would otherwise run', () => {
57
+ // Parking a name the lane never selects gates nothing and hides nothing; it just
58
+ // makes the list look like it is doing more than it is.
59
+ const notInLane = [...INTEGRATION_PARKED].filter((name) => !INTEGRATION.has(name)).sort();
60
+ assert.deepEqual(notInLane, [],
61
+ 'these are parked but are not in the INTEGRATION set, so the lane was never going '
62
+ + 'to run them. Add them to INTEGRATION or drop the parked entry.');
63
+ });
64
+
65
+ test('no test is parked in both INTEGRATION_PARKED and KNOWN_FAILING', () => {
66
+ // They are different mechanisms with different ratchets. A name in both means two
67
+ // lists claim ownership and only one of them will ever be the reason it is skipped,
68
+ // so removing it from one would look like a fix while the other kept it excluded.
69
+ const both = [...INTEGRATION_PARKED].filter((name) => KNOWN_FAILING.has(name)).sort();
70
+ assert.deepEqual(both, [],
71
+ 'parked in two places at once — pick the mechanism that owns each test.');
72
+ });
73
+
74
+ test('the parked count is ratcheted and cannot grow silently', () => {
75
+ const n = INTEGRATION_PARKED.size;
76
+ assert.ok(n <= MAX_PARKED,
77
+ `${n} parked integration tests exceeds the ratchet of ${MAX_PARKED}. Fix the new `
78
+ + 'failure rather than parking it; raising MAX_PARKED is a deliberate act.');
79
+ });
@@ -9,7 +9,7 @@
9
9
  import assert from 'node:assert/strict';
10
10
  import { test } from 'node:test';
11
11
  import { createRequire } from 'node:module';
12
- import { readFileSync } from 'node:fs';
12
+ import { readFileSync, readdirSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
@@ -191,20 +191,75 @@ test('craft picker options: OTB.disciplines() mirrors the server list, and falls
191
191
  assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator', 'ui']);
192
192
  });
193
193
 
194
- test('craft picker options: disciplineLabel names an acronym correctly', () => {
194
+ test('craft picker options: roleLabel names an acronym correctly', () => {
195
195
  global.window = {};
196
196
  // the IIFE assigns window.OTB once at load — evict it for a fresh binding
197
197
  const domUtils = path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js');
198
198
  delete require.cache[require.resolve(domUtils)];
199
199
  require(domUtils);
200
200
  const OTB = global.window.OTB;
201
- assert.equal(OTB.disciplineLabel('ui'), 'UI', 'not "Ui"');
202
- assert.equal(OTB.disciplineLabel('engineer'), 'Engineer');
203
- assert.equal(OTB.disciplineLabel('artist'), 'Artist');
204
- assert.equal(OTB.disciplineLabel('ideator'), 'Ideator');
201
+ assert.equal(OTB.roleLabel('ui'), 'UI', 'not "Ui"');
202
+ assert.equal(OTB.roleLabel('engineer'), 'Engineer');
203
+ assert.equal(OTB.roleLabel('artist'), 'Artist');
204
+ assert.equal(OTB.roleLabel('ideator'), 'Ideator');
205
205
  // a craft a module adds later needs no new entry here
206
- assert.equal(OTB.disciplineLabel('archivist'), 'Archivist');
207
- for (const bad of ['', null, undefined]) assert.equal(OTB.disciplineLabel(bad), '');
206
+ assert.equal(OTB.roleLabel('archivist'), 'Archivist');
207
+ for (const bad of ['', null, undefined]) assert.equal(OTB.roleLabel(bad), '');
208
+ });
209
+
210
+ // ---- task 1003936: ONE word for the role axis, and it lives in one place -----
211
+
212
+ test('the role axis has exactly one user-facing word, and no surface types it', () => {
213
+ // The bug this pins: the hall shipped FOUR words for one axis — Discipline on the
214
+ // Tasks board, Discipline AND Craft on the Goals page 77 lines apart, Craft in the
215
+ // Directory and Settings, craft in Collab, Type/lane on the builder-start card.
216
+ // ADR 0174 Decision 5 settled the word as "role"; the owner confirmed it as the
217
+ // shipped label on 2026-09-18. A label repeated in six files drifts back, so the
218
+ // real invariant is not "the word is Role" but "no surface spells it itself".
219
+ global.window = {};
220
+ const domUtils = path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js');
221
+ delete require.cache[require.resolve(domUtils)];
222
+ require(domUtils);
223
+ const OTB = global.window.OTB;
224
+ assert.equal(OTB.roleAxisLabel(), 'Role');
225
+
226
+ const readSrc = (p) => readFileSync(path.join(ROOT, p), 'utf8');
227
+ const SURFACES = [
228
+ 'modules/hall-ui/public/work.js',
229
+ 'modules/hall-ui/public/goals-page.js',
230
+ 'modules/hall-ui/public/people.js',
231
+ 'modules/hall-ui/public/profile.js',
232
+ 'modules/hall-ui/public/builders.js',
233
+ ];
234
+ for (const p of SURFACES) {
235
+ const s = readSrc(p);
236
+ // No surface may carry a hand-typed label for the axis...
237
+ assert.ok(!/label: '(Craft|Discipline|Role)'/.test(s), p + ' types the axis label instead of reading roleAxisLabel()');
238
+ assert.ok(!/'Crafts'|'Disciplines'|'Roles'/.test(s), p + ' types the plural axis label');
239
+ // ...and every one of them must actually read the shared vocabulary.
240
+ assert.match(s, /OTB\.role(AxisLabel|Label|Options|IsUnset)\(/, p + ' does not read the shared role vocabulary');
241
+ }
242
+ });
243
+
244
+ test('an unset role is never shown to a person as the database default', () => {
245
+ // 'unclassified' is a column default, not a word anyone would say (docs/voice.md
246
+ // V2). It may remain the stored value and the filter VALUE; it may not be what a
247
+ // person reads.
248
+ global.window = {};
249
+ const domUtils = path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js');
250
+ delete require.cache[require.resolve(domUtils)];
251
+ require(domUtils);
252
+ const OTB = global.window.OTB;
253
+ assert.equal(OTB.ROLE_UNSET, 'unclassified', 'the stored value is unchanged');
254
+ assert.equal(OTB.roleLabel('unclassified'), 'No role yet');
255
+ assert.ok(OTB.roleIsUnset('unclassified') && OTB.roleIsUnset(null) && OTB.roleIsUnset(''));
256
+ assert.ok(!OTB.roleIsUnset('engineer'));
257
+
258
+ const readSrc = (p) => readFileSync(path.join(ROOT, p), 'utf8');
259
+ // The role-bearing render sites must not interpolate the raw default into markup.
260
+ for (const p of ['modules/hall-ui/public/work.js', 'modules/hall-ui/public/profile.js', 'modules/hall-ui/public/profile-charts.js']) {
261
+ assert.ok(!/t\.discipline \|\| 'unclassified'/.test(readSrc(p)), p + ' still renders the stored default');
262
+ }
208
263
  });
209
264
 
210
265
  test('craft picker options: neither picker hardcodes the craft list any more', () => {
@@ -268,3 +323,74 @@ test('hall-widget registry: placement rules reject bad registrations, accept goo
268
323
  delete global.window;
269
324
  }
270
325
  });
326
+
327
+ // ---- task 1004039: the role axis on the PUBLIC LEGAL surface ----------------
328
+
329
+ test('the public landing pages spell the role axis one way, and Terms actually says it', () => {
330
+ // Task 1003936 unified the word to "Role" across the hall, the status dashboard and
331
+ // the builder-start card, but deliberately left Terms alone: it is a public legal
332
+ // surface, and rewording one is a different decision from renaming a filter label.
333
+ // That left a FIFTH spelling in the wild — "the disciplines you worked in" — on the
334
+ // one page whose whole job is to describe accurately what the platform publishes
335
+ // about a person.
336
+ //
337
+ // OWNER CONFIRMATION (the task's third done-when: confirm before shipping, because a
338
+ // legal surface is not a pure copy edit). Asked and answered 2026-09-19 — the owner
339
+ // was shown the current sentence, the proposed "the roles you worked in", and that
340
+ // the change is label-only with the published fields unchanged, and chose to ship it.
341
+ // Recorded here because a reviewer reads the diff, not the conversation.
342
+ //
343
+ // Scanned as RENDERED TEXT, not source lines. Task 1003936 burned four grading
344
+ // rounds on greps keyed to the shape the word was expected to have: a sentence split
345
+ // across three lines inside one <p> defeats a line-anchored HTML grep. Strip
346
+ // script/style/comments/tags, collapse whitespace, then scan the prose a reader sees.
347
+ const dir = path.join(ROOT, 'modules', 'public-landing', 'public');
348
+ const renderedText = (file) => readFileSync(path.join(dir, file), 'utf8')
349
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
350
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
351
+ .replace(/<!--[\s\S]*?-->/g, ' ')
352
+ .replace(/<[^>]+>/g, ' ')
353
+ .replace(/\s+/g, ' ');
354
+
355
+ const pages = readdirSync(dir).filter((f) => f.endsWith('.html'));
356
+ assert.ok(pages.includes('terms.html'), 'terms.html must be among the scanned pages');
357
+
358
+ // The superseded spellings task 1003936 swept: Discipline, Craft, Type and lane.
359
+ // Three of the four can be matched as bare words — none has an innocent use on these
360
+ // pages (verified: zero hits for lane, and the craft/discipline hits were this bug).
361
+ const BARE = [
362
+ [/\bdisciplines?\b/i, 'discipline'],
363
+ [/\bcrafts?\b/i, 'craft'],
364
+ [/\blanes?\b/i, 'lane'],
365
+ ];
366
+ // "type" and "domain" CANNOT be matched bare, and this is the interesting case rather
367
+ // than an omission. Both have innocent uses here that must survive: projects.html says
368
+ // "point a domain you own at it" (DNS) and "type the name here" (the verb), index.html
369
+ // has a "Types of projects" heading and projects.html a "Type" column (a PROJECT's
370
+ // type, a different axis entirely). A bare scan would flag four correct strings, and
371
+ // the exception list needed to silence them is exactly where the next stale spelling
372
+ // would hide. So match the SHAPES the role axis takes in a sentence instead.
373
+ const AXIS_SHAPED = [
374
+ [/\btypes?\s+you\s+worked\s+in\b/i, 'type ("the types you worked in")'],
375
+ [/\bdomains?\s+you\s+worked\s+in\b/i, 'domain ("the domains you worked in")'],
376
+ [/\bby\s+(type|lane|craft|discipline|domain)\b/i, 'the axis in a "by <word>" filter label'],
377
+ [/\byour\s+(type|lane|craft|discipline)\b/i, 'the axis in a "your <word>" label'],
378
+ ];
379
+ for (const page of pages) {
380
+ const text = renderedText(page);
381
+ for (const [re, word] of BARE) {
382
+ assert.ok(!re.test(text), `${page} still spells the role axis "${word}"`);
383
+ }
384
+ for (const [re, what] of AXIS_SHAPED) {
385
+ assert.ok(!re.test(text), `${page} still spells the role axis as ${what}`);
386
+ }
387
+ }
388
+
389
+ // The negative assertions above all pass on a page that simply deleted the sentence,
390
+ // so pin the POSITIVE too — the clause must still be there, saying "roles".
391
+ assert.match(
392
+ renderedText('terms.html'),
393
+ /the rank that project gave you, the roles you worked in, and when you were last active/,
394
+ 'terms.html no longer states, in the shipped wording, which role axis the platform publishes',
395
+ );
396
+ });
@@ -102,6 +102,13 @@ function harness({ count }) {
102
102
  matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
103
103
  OTBBranding: { currencyLabel: () => 'Credits' },
104
104
  OTB: {
105
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
106
+ // the same reason categoryChip is: an undefined helper throws mid-render.
107
+ roleAxisLabel: () => 'Role',
108
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
109
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
110
+ roleIsUnset: (d) => !d || d === 'unclassified',
111
+ ROLE_UNSET: 'unclassified',
105
112
  escapeHtml: (s) => String(s == null ? '' : s),
106
113
  $: (sel) => documentObj.querySelector(sel),
107
114
  num: (n) => Number(n) || 0,
@@ -517,6 +517,13 @@ function bootProfile({ signedIn = true } = {}) {
517
517
  BongosClient: { createClient: () => client },
518
518
  OTBKit: kit,
519
519
  OTB: {
520
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
521
+ // the same reason categoryChip is: an undefined helper throws mid-render.
522
+ roleAxisLabel: () => 'Role',
523
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
524
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
525
+ roleIsUnset: (d) => !d || d === 'unclassified',
526
+ ROLE_UNSET: 'unclassified',
520
527
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
521
528
  $: (sel) => documentObj.querySelector(sel),
522
529
  fmtNum: (n) => String(n ?? 0),
@@ -79,6 +79,13 @@ async function boot({ search = '', me, profile } = {}) {
79
79
  __BRANDING__: {},
80
80
  BongosClient: { createClient: () => api },
81
81
  OTB: {
82
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
83
+ // the same reason categoryChip is: an undefined helper throws mid-render.
84
+ roleAxisLabel: () => 'Role',
85
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
86
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
87
+ roleIsUnset: (d) => !d || d === 'unclassified',
88
+ ROLE_UNSET: 'unclassified',
82
89
  $, escapeHtml,
83
90
  fmtNum: (n) => String(Number(n) || 0),
84
91
  fmtDate: (d) => String(d).slice(0, 10),
@@ -161,7 +168,8 @@ test('full cross_project → tiles, named rows, plain-text standing, +N more', a
161
168
  assert.match(html, /Metic/, 'rank_held renders capitalized as that project\'s own standing');
162
169
  assert.ok(!html.includes('rank-badge'), 'never the local rank-badge component for another project\'s rank (spec Decision 1)');
163
170
  assert.match(html, /\+ 1 more project/, 'non-public projects are counted, never named');
164
- assert.match(html, /Disciplines: engineer, ui/);
171
+ // task 1003936: one word for the axis, and the values are named not slugged.
172
+ assert.match(html, /Roles: Engineer, UI/);
165
173
  assert.match(html, /Last active 2026-08-01/);
166
174
  assert.ok(!html.includes('/settings#cross-project'), 'no self link for a stranger');
167
175
  });
@@ -28,6 +28,13 @@ const require = createRequire(import.meta.url);
28
28
  const P = require('../scripts/gds/provision.js');
29
29
  const D = require('../scripts/gds/provision-disconnect.js');
30
30
 
31
+ // The assertions below pin the supervision verbs as `systemctl …` (task 1003997, ADR 0306).
32
+ // The manager is PROBED from /run/systemd/system when nothing pins it, so a CI runner that
33
+ // boots systemd and a container that does not disagree on the same source. Pin it here — these
34
+ // tests are about the runbook, not about what the test box happens to boot. The probe itself is
35
+ // covered directly, with an injected fs, in tests/provision.mjs.
36
+ process.env.PROVISION_PROCESS_MANAGER = 'systemd';
37
+
31
38
  let passed = 0, failed = 0;
32
39
  function t(name, fn) {
33
40
  try { fn(); passed++; console.log(` PASS ${name}`); }
@@ -32,6 +32,13 @@ const require = createRequire(import.meta.url);
32
32
  const P = require('../scripts/gds/provision.js');
33
33
  const { MANIFEST_VERIFY_TRIES } = require('../scripts/gds/provision-config.js');
34
34
 
35
+ // The assertions below pin the supervision verbs as `systemctl …` (task 1003997, ADR 0306).
36
+ // The manager is PROBED from /run/systemd/system when nothing pins it, so a CI runner that
37
+ // boots systemd and a container that does not disagree on the same source. Pin it here — these
38
+ // tests are about the runbook, not about what the test box happens to boot. The probe itself is
39
+ // covered directly, with an injected fs, in tests/provision.mjs.
40
+ process.env.PROVISION_PROCESS_MANAGER = 'systemd';
41
+
35
42
  let passed = 0, failed = 0;
36
43
  async function ta(name, fn) {
37
44
  try { await fn(); passed++; console.log(` PASS ${name}`); }
@@ -28,6 +28,13 @@ const require = createRequire(import.meta.url);
28
28
  const P = require('../scripts/gds/provision.js');
29
29
  const { MANIFEST_VERIFY_TRIES } = require('../scripts/gds/provision-config.js');
30
30
 
31
+ // The assertions below pin the supervision verbs as `systemctl …` (task 1003997, ADR 0306).
32
+ // The manager is PROBED from /run/systemd/system when nothing pins it, so a CI runner that
33
+ // boots systemd and a container that does not disagree on the same source. Pin it here — these
34
+ // tests are about the runbook, not about what the test box happens to boot. The probe itself is
35
+ // covered directly, with an injected fs, in tests/provision.mjs.
36
+ process.env.PROVISION_PROCESS_MANAGER = 'systemd';
37
+
31
38
  const NEUTRAL_PREFIX =
32
39
  JSON.parse(readFileSync(new URL('../config/branding.neutral.json', import.meta.url), 'utf8')).envPrefix || 'CLOUDBONGOS';
33
40
 
@@ -118,6 +118,13 @@ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPageP
118
118
  scrollTo() {},
119
119
  addEventListener() {},
120
120
  OTB: {
121
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
122
+ // the same reason categoryChip is: an undefined helper throws mid-render.
123
+ roleAxisLabel: () => 'Role',
124
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
125
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
126
+ roleIsUnset: (d) => !d || d === 'unclassified',
127
+ ROLE_UNSET: 'unclassified',
121
128
  escapeHtml: (s) => String(s == null ? '' : s),
122
129
  $: (sel) => documentObj.querySelector(sel),
123
130
  num: (n) => Number(n) || 0,
@@ -159,6 +159,13 @@ async function boot(opts = {}) {
159
159
  const windowObj = {
160
160
  __BRANDING__: { project: { joinability: 'apply' } },
161
161
  OTB: {
162
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
163
+ // the same reason categoryChip is: an undefined helper throws mid-render.
164
+ roleAxisLabel: () => 'Role',
165
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
166
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
167
+ roleIsUnset: (d) => !d || d === 'unclassified',
168
+ ROLE_UNSET: 'unclassified',
162
169
  escapeHtml,
163
170
  $: (sel) => documentObj.querySelector(sel),
164
171
  num: (v) => Number(v),
@@ -129,6 +129,13 @@ async function boot(opts = {}) {
129
129
  const windowObj = {
130
130
  __BRANDING__: { project: { joinability: 'apply' } },
131
131
  OTB: {
132
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
133
+ // the same reason categoryChip is: an undefined helper throws mid-render.
134
+ roleAxisLabel: () => 'Role',
135
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
136
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
137
+ roleIsUnset: (d) => !d || d === 'unclassified',
138
+ ROLE_UNSET: 'unclassified',
132
139
  escapeHtml,
133
140
  $: (sel) => documentObj.querySelector(sel),
134
141
  num: (v) => Number(v),
@@ -116,6 +116,13 @@ async function boot({ answers = {}, fallback = ok({}) } = {}) {
116
116
  const windowObj = {
117
117
  __BRANDING__: { project: { joinability: 'apply' } },
118
118
  OTB: {
119
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
120
+ // the same reason categoryChip is: an undefined helper throws mid-render.
121
+ roleAxisLabel: () => 'Role',
122
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
123
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
124
+ roleIsUnset: (d) => !d || d === 'unclassified',
125
+ ROLE_UNSET: 'unclassified',
119
126
  escapeHtml,
120
127
  $: (sel) => documentObj.querySelector(sel),
121
128
  num: (v) => Number(v),