@bongos/core 1.20.83 → 1.21.2

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 (96) hide show
  1. package/.bongos-core.json +186 -81
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +60 -59
  14. package/docs/copy-registry.json +72 -63
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +9 -3
  19. package/docs/page-readings.json +427 -410
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/migrations/core_270_craft_achievements.sql +128 -0
  23. package/modules/economy/achievements.js +199 -4
  24. package/modules/economy/credits.js +16 -0
  25. package/modules/economy/reward.js +3 -0
  26. package/modules/hall-ui/public/builders.js +1 -1
  27. package/modules/hall-ui/public/genesis-home.js +17 -3
  28. package/modules/hall-ui/public/hall-render.js +24 -2
  29. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  30. package/modules/hall-ui/public/roster.css +21 -0
  31. package/modules/hall-ui/public/roster.html +3 -0
  32. package/modules/hall-ui/public/roster.js +121 -21
  33. package/modules/hall-ui/public/roster.states.json +26 -1
  34. package/modules/hall-ui/records/builders-roster.md +20 -0
  35. package/modules/lifecycle/publish-reconciler.js +25 -0
  36. package/modules/lifecycle/workflow-dispatch.js +21 -11
  37. package/modules/npm-release/release.js +10 -1
  38. package/modules/onboarding/founding-birth.js +116 -0
  39. package/modules/onboarding/genesis-home.js +72 -5
  40. package/modules/onboarding/port.js +9 -2
  41. package/modules/onboarding/routes/onboarding.js +8 -1
  42. package/modules/provisioning/birth.js +64 -0
  43. package/modules/provisioning/dns-resolves.js +54 -0
  44. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  45. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  46. package/modules/provisioning/provisioning.js +7 -6
  47. package/modules/provisioning/routes/provisioning.js +6 -2
  48. package/modules/provisioning/tests/liveness.mjs +48 -0
  49. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  50. package/modules/public-landing/public/projects.html +40 -15
  51. package/modules/sessions/db.js +58 -0
  52. package/modules/sessions/routes/sessions.js +97 -0
  53. package/modules/status-ui/public/status.js +39 -7
  54. package/modules/status-ui/public/style.css +24 -0
  55. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  56. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  57. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  58. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  59. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +45 -0
  63. package/scripts/gds/provision.js +77 -53
  64. package/scripts/gds/session-digest.js +11 -3
  65. package/scripts/gds/session-transcript-build.js +155 -0
  66. package/scripts/gds/session-transcript-upload.js +29 -0
  67. package/scripts/gds/ship-session-upload.js +6 -1
  68. package/scripts/gds/upgrade-commit-identity.js +32 -0
  69. package/scripts/gds/upgrade.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +2 -0
  71. package/src/bongos/db.js +13 -0
  72. package/src/bongos/role-stats.js +36 -2
  73. package/src/bongos/routes/public.js +13 -2
  74. package/src/bongos/routes.js +3 -0
  75. package/src/branding.js +9 -0
  76. package/src/module-api.js +1 -1
  77. package/tests/achievements_reconcile.mjs +13 -0
  78. package/tests/craft_achievements.mjs +349 -0
  79. package/tests/founding_birth.mjs +200 -0
  80. package/tests/founding_builders.mjs +2 -1
  81. package/tests/founding_mode.mjs +3 -1
  82. package/tests/genesis_home.mjs +71 -2
  83. package/tests/hall_builders_page_pass.mjs +2 -1
  84. package/tests/hub_map_orb_states.mjs +5 -1
  85. package/tests/leaderboard_roles.mjs +272 -0
  86. package/tests/npm_release_release.mjs +37 -2
  87. package/tests/projects_hub.mjs +5 -3
  88. package/tests/projects_hub_dns_ready.mjs +153 -0
  89. package/tests/projects_hub_pre_uat.mjs +9 -5
  90. package/tests/provision_dns_first.mjs +171 -0
  91. package/tests/provision_settings_apply.mjs +6 -1
  92. package/tests/provisioning_settings_env.mjs +49 -0
  93. package/tests/session_transcript.mjs +273 -0
  94. package/tests/ui_design_kit.mjs +1 -1
  95. package/tests/upgrade_commit_identity.mjs +89 -0
  96. package/tests/wizard_demo.mjs +29 -3
@@ -34,6 +34,9 @@
34
34
  link into index.html. It is a page now; /#leaderboard redirects here. -->
35
35
  <section class="scroll" id="roster-scroll" aria-labelledby="roster-h">
36
36
  <h2 id="roster-h" class="scroll__h">The roster<span class="count-pill" id="roster-count" hidden></span></h2>
37
+ <!-- The role toggle (task 1004437, owner decision (b)): Everyone, then one
38
+ chip per craft; a craft ranks by the credits earned in it. -->
39
+ <div class="roster-roles" id="roster-roles" role="group" aria-label="Show"></div>
37
40
  <div id="roster-filters"></div>
38
41
  <div id="roster-body">
39
42
  <div class="profile__placeholder">Loading the roster…</div>
@@ -49,17 +49,31 @@
49
49
  'first-parallel-claim': '⚓',
50
50
  'streak-3': '\u{1F525}',
51
51
  'streak-7': '\u{1F30B}',
52
- 'art-pipeline-shipper': '\u{1F3A8}',
52
+ // task 1004438: renamed Art Pipeline Engineer; the palette is the artist's now
53
+ 'art-pipeline-shipper': '\u{1F6E0}\u{FE0F}',
54
+ 'first-thought-ignited': '\u{1F4A1}',
55
+ 'first-full-idea': '\u{1F4DC}',
56
+ 'first-board-ratification': '\u{1F3DB}\u{FE0F}',
57
+ 'thought-built-ten': '\u{1F31F}',
58
+ 'first-page-approved': '\u{1F3A8}',
59
+ 'ten-pages-approved': '\u{1F5BC}\u{FE0F}',
60
+ 'whole-surface-tweaked': '\u{1F5FA}\u{FE0F}',
53
61
  'gds-shipper': '⚙️',
54
62
  'breaking-the-100c-bar': '\u{1F4AF}',
55
63
  };
64
+ // An achievement id as a sentence: "first-page-approved" → "First page approved".
65
+ function achWords(id) {
66
+ var s = String(id || '').replace(/-/g, ' ');
67
+ return s.charAt(0).toUpperCase() + s.slice(1);
68
+ }
56
69
  function achRowHtml(ids) {
57
70
  if (!Array.isArray(ids) || !ids.length) return '';
58
71
  var inner = ids.map(function (id) {
59
72
  var emoji = ACH_EMOJI[id];
60
- // Title is the achievement id (kebab) — terse enough that hover discloses
61
- // the laurel without overstating the prose.
62
- return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
73
+ // The title is the achievement's name as words ("First page approved"), not
74
+ // its id slug (task 1004438): it is what a hover shows and a screen reader says.
75
+ var words = achWords(id);
76
+ return emoji ? '<span class="lb-ach" title="' + esc(words) + '" aria-label="' + esc(words) + '">' + emoji + '</span>' : '';
63
77
  }).join('');
64
78
  return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
65
79
  }
@@ -94,9 +108,34 @@
94
108
  return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
95
109
  }
96
110
 
111
+ // task 1004437 (WA6.RP07): the roles. Each row's `roles` block is the public
112
+ // list's own (src/bongos/role-stats.js readRolesForBuilders): `shown` is the
113
+ // main role first, then every craft the builder has EARNED in — evidence of
114
+ // work, not only what they declared — and `credits_by_craft` is the ledger
115
+ // split. Owner decision (b): a column naming them AND the role toggle above,
116
+ // which ranks by the credits earned in that craft. The figure a role ranks by
117
+ // is in the row (ADR 0255: a public list is ordered only by what it publishes).
118
+ var CRAFTS = ['engineer', 'artist', 'ideator'];
119
+ var CRAFT_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
120
+ var ROLE_CHIPS = [['', 'Everyone'], ['engineer', 'Engineers'], ['artist', 'Artists'], ['ideator', 'Ideators']];
121
+ function shownOf(b) { return (b.roles && Array.isArray(b.roles.shown)) ? b.roles.shown : []; }
122
+ function craftCredits(b, craft) {
123
+ var by = b.roles && b.roles.credits_by_craft;
124
+ return by ? (Number(by[craft]) || 0) : 0;
125
+ }
126
+ function creditOf(b) { return role ? craftCredits(b, role) : (Number(b.total_credits) || 0); }
127
+ function rolesHtml(b) {
128
+ var shown = shownOf(b);
129
+ if (!shown.length) return '<span class="lb-roles lb-roles--none">—</span>';
130
+ return '<span class="lb-roles">' + shown.map(function (c, i) {
131
+ return '<span class="lb-role' + (i === 0 ? ' lb-role--lead' : '') + '">' + esc(CRAFT_WORD[c] || c) + '</span>';
132
+ }).join('') + '</span>';
133
+ }
134
+
97
135
  var all = [];
98
136
  var query = '';
99
137
  var sortBy = '';
138
+ var role = '';
100
139
 
101
140
  // PLACE IS CANONICAL, not the row's position in the current sort.
102
141
  // hall-render's leaderboardPlace() already defines a builder's place as
@@ -108,6 +147,16 @@
108
147
  rows.slice()
109
148
  .sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
110
149
  .forEach(function (b, i) { b.__place = i + 1; });
150
+ // A role's place is the same idea inside that role: credits earned in the
151
+ // craft, descending, among the builders it is shown for. Stamped once too,
152
+ // so the place under a role toggle does not renumber under a search.
153
+ rows.forEach(function (b) { b.__placeBy = {}; });
154
+ CRAFTS.forEach(function (c) {
155
+ rows.filter(function (b) { return shownOf(b).indexOf(c) !== -1; })
156
+ .map(function (b, i) { return { b: b, i: i }; })
157
+ .sort(function (x, y) { return (craftCredits(y.b, c) - craftCredits(x.b, c)) || (x.i - y.i); })
158
+ .forEach(function (x, i) { x.b.__placeBy[c] = i + 1; });
159
+ });
111
160
  // The efficiency title is stamped here too, so the division and the string
112
161
  // work happen ONCE per load rather than per visible row per render (every
113
162
  // sort, search keystroke and page turn re-renders).
@@ -116,6 +165,7 @@
116
165
  }
117
166
 
118
167
  function matches(b) {
168
+ if (role && shownOf(b).indexOf(role) === -1) return false;
119
169
  if (!query) return true;
120
170
  var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
121
171
  return hay.indexOf(query) !== -1;
@@ -130,7 +180,8 @@
130
180
  tokens: function (b) { return -(Number(b.tokens_used) || 0); },
131
181
  };
132
182
  function ordered(rows) {
133
- var key = SORTS[sortBy];
183
+ // The default lens is credits; under a role it is the credits that role earned.
184
+ var key = SORTS[sortBy] || (role ? function (b) { return -craftCredits(b, role); } : null);
134
185
  if (!key) return rows;
135
186
  return rows.map(function (b, i) { return { b: b, i: i }; })
136
187
  .sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
@@ -140,7 +191,14 @@
140
191
  var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
141
192
  var ledger = null;
142
193
 
194
+ // Tokens only mean something for AI sessions. A builder who works in the
195
+ // browser (an artist in the studio, an ideator in the room) records none, and
196
+ // a 0 beside them reads as "did nothing" (task 1004437): their cell says so in
197
+ // words instead, and the tokens-per-credit figure is offered only where there
198
+ // are tokens to divide.
199
+ function hasTokens(b) { return (Number(b.tokens_used) || 0) > 0; }
143
200
  function efficiencyTitle(b) {
201
+ if (!hasTokens(b)) return 'No AI sessions recorded — this builder’s work is not measured in tokens';
144
202
  var credits = Number(b.total_credits) || 0;
145
203
  var one = currencyLabel().toLowerCase().replace(/s$/, '');
146
204
  if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
@@ -168,20 +226,69 @@
168
226
  }
169
227
 
170
228
  function rowHtml(b) {
229
+ var place = role ? (b.__placeBy && b.__placeBy[role]) : b.__place;
230
+ var tokens = hasTokens(b)
231
+ ? esc(fmtInt(b.tokens_used))
232
+ : '<span class="lb-none" aria-label="No AI sessions">—</span>';
171
233
  return '<tr>'
172
- + '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
234
+ + '<td class="ledger__num ledger__place">' + esc(String(place || '—')) + idHtml(b) + '</td>'
173
235
  + '<td>' + whoHtml(b) + foundingHtml(b) + achRowHtml(b.achievements) + '</td>'
236
+ + '<td class="ledger__roles">' + rolesHtml(b) + '</td>'
174
237
  + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
175
- + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
238
+ + '<td class="ledger__num">' + esc(fmtInt(creditOf(b))) + '</td>'
176
239
  + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
177
- + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + esc(fmtInt(b.tokens_used)) + '</td>'
240
+ + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + tokens + '</td>'
178
241
  + '</tr>';
179
242
  }
180
243
 
244
+ function columns() {
245
+ return ['Place', 'Builder', 'Roles', 'Rank',
246
+ { label: role ? currencyLabel() + ' as ' + CRAFT_WORD[role].toLowerCase() : currencyLabel(), class: 'ledger__num' },
247
+ { label: 'Karma', class: 'ledger__num' },
248
+ { label: 'Tokens', class: 'ledger__num' }];
249
+ }
250
+
251
+ function makeRosterLedger() {
252
+ body.textContent = '';
253
+ body.className = '';
254
+ ledger = kit.makeLedger({
255
+ mount: body,
256
+ columns: columns(),
257
+ rowHtml: rowHtml,
258
+ emptyMsg: role
259
+ ? 'No builder on the roster has earned as ' + CRAFT_WORD[role].toLowerCase() + ' yet, or none matches that search.'
260
+ : 'No builder on the roster matches that search.',
261
+ pager: pager,
262
+ });
263
+ }
264
+
181
265
  function rerender() {
182
266
  if (ledger) ledger.render(ordered(all.filter(matches)));
183
267
  }
184
268
 
269
+ // The role toggle (owner decision (b)): Everyone, then one per craft. A pressed
270
+ // chip ranks by the credits that craft earned and shows only the builders it
271
+ // is shown for. The ledger is rebuilt so the credits header names the craft.
272
+ function roleChipsHtml() {
273
+ return ROLE_CHIPS.map(function (c) {
274
+ return '<button type="button" class="roster-role" data-role="' + esc(c[0]) + '" aria-pressed="' + (c[0] === role) + '">' + esc(c[1]) + '</button>';
275
+ }).join('');
276
+ }
277
+ function mountRoleChips() {
278
+ var mount = document.getElementById('roster-roles');
279
+ if (!mount) return;
280
+ mount.innerHTML = roleChipsHtml();
281
+ mount.addEventListener('click', function (e) {
282
+ var btn = e.target.closest('[data-role]');
283
+ if (!btn || btn.dataset.role === role) return;
284
+ role = btn.dataset.role;
285
+ mount.querySelectorAll('[data-role]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.role === role)); });
286
+ pager.page = 1;
287
+ makeRosterLedger();
288
+ rerender();
289
+ });
290
+ }
291
+
185
292
  function notice(msg) {
186
293
  body.innerHTML = '';
187
294
  body.className = 'note';
@@ -221,23 +328,16 @@
221
328
  rerender();
222
329
  },
223
330
  });
224
- body.textContent = '';
225
- body.className = '';
226
- ledger = kit.makeLedger({
227
- mount: body,
228
- columns: ['Place', 'Builder', 'Rank',
229
- { label: currencyLabel(), class: 'ledger__num' },
230
- { label: 'Karma', class: 'ledger__num' },
231
- { label: 'Tokens', class: 'ledger__num' }],
232
- rowHtml: rowHtml,
233
- emptyMsg: 'No builder on the roster matches that search.',
234
- pager: pager,
235
- });
331
+ mountRoleChips();
332
+ makeRosterLedger();
236
333
  rerender();
237
334
  }
238
335
 
239
336
  var foundingLoad = window.OTBFounding ? window.OTBFounding.load() : Promise.resolve([]);
240
- fetch('/api/bongos/leaderboard', { headers: { Accept: 'application/json' } })
337
+ // The PUBLIC list (task 1004437): the same rows the authed /leaderboard
338
+ // carries plus each builder's roles, and the one the status page reads, so
339
+ // the hall and the public page cannot rank a role differently.
340
+ fetch('/api/bongos/public/leaderboard', { headers: { Accept: 'application/json' } })
241
341
  .then(function (r) {
242
342
  if (!r.ok) throw new Error('http ' + r.status);
243
343
  return r.json();
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/leaderboard.json carries thirteen builders with avatars, ranks, karma and tokens).",
2
+ "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike a gated page's states file the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/public__leaderboard.json, since task 1004437 the list the page reads: fifteen builders with avatars, ranks, karma, tokens and each one's roles, including an artist and an ideator with no AI sessions).",
3
3
  "page": "/builders/roster",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -44,6 +44,31 @@
44
44
  ]
45
45
  }
46
46
  },
47
+ "artists": {
48
+ "_": "The role toggle with Artists pressed (task 1004437, owner decision (b)): only the builders shown as artists, ranked by the credits that craft earned, the credits header naming the craft. Against the harness the browser-only artist (2001) leads, with a dash where an AI builder's tokens would be. The harness serves public__leaderboard.json, which carries the roles.",
49
+ "auth": true,
50
+ "actions": [
51
+ [
52
+ "wait",
53
+ 700
54
+ ],
55
+ [
56
+ "click",
57
+ "[data-role=\"artist\"]"
58
+ ],
59
+ [
60
+ "wait",
61
+ 200
62
+ ]
63
+ ],
64
+ "expect": {
65
+ "visible": [
66
+ "#roster-scroll",
67
+ "#roster-roles",
68
+ ".lb-roles"
69
+ ]
70
+ }
71
+ },
47
72
  "search": {
48
73
  "auth": true,
49
74
  "actions": [
@@ -8,3 +8,23 @@
8
8
  - **It is deliberately UNGATED,** and `tests/hall_page_gate_map.mjs` spells that out rather than defaulting. Its one data source, `GET /leaderboard`, is deliberately unauthenticated and mirrors the public status surface; a page must not be gated tighter than the API it fronts, and it was already reachable to a stranger as a view on the ungated index. No visibility predicate is applied and that is correct — privacy suppresses cross-project surfaces only, so a `private` builder still appears on the roster of a project they build on (ADR 0225).
9
9
 
10
10
  Pinned by `tests/hall_nav.mjs` (the Community group's middle id is `roster`) and `tests/hall_page_gate_map.mjs`. Measured through `roster.states.json` — whose `out` state renders the REAL page, since the page is ungated.
11
+
12
+ ## The roles (task 1004437, WA6.RP07)
13
+
14
+ Owner decision (b), 2026-09-30: the leaderboard gets BOTH a column naming each builder's roles and the Everyone / Engineers / Artists / Ideators toggle above it. The approved mock is `docs/design/mocks/profile-roles/` (`?state=board`, `?state=board-artists`).
15
+
16
+ - **The roster now reads `GET /public/leaderboard`,** the list the status page reads, which carries each row's `roles` (`src/bongos/role-stats.js` `readRolesForBuilders`: the profile's own main/shown rules, two reads for the whole page) and, since this task, `karma`. The hall and the public page cannot rank a role differently. The authed `/leaderboard` is unchanged and no longer read by the hall.
17
+ - **Roles column:** one pill per role in `roles.shown`, the main one a step stronger. `shown` is the main role plus every craft the builder has EARNED in, so it is evidence of work, not only a declared preference.
18
+ - **The toggle** keeps the builders a craft is shown for and ranks them by the credits that craft earned (`roles.credits_by_craft`, in the row: ADR 0255). The place column becomes the place inside that craft, the credits header says "Credits as artist", and the karma/rank/tokens lenses still apply on top.
19
+ - **Tokens only where they apply.** A builder with no recorded AI session (an artist in the studio, an ideator in the room) reads a dash, and the cell's tooltip says no AI sessions were recorded, instead of a 0 and a "0 tokens per credit" figure that read as "did nothing".
20
+ - **Home's Standing card** reads the same list and adds "#N as an artist" beside "#N on the leaderboard" when the place inside the builder's main role differs from the overall one (`hall-render.js` `rolePlaceText`).
21
+ - **The status page** names each builder's roles under their name, on that page's own palette (`status.js` `renderRoles`).
22
+
23
+ Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js over the harness fixture with the toggle clicked, the Standing card's place and the status renderer, run). The `artists` state in `roster.states.json` renders the toggle pressed.
24
+
25
+ ### Deliberate differences from the mock (RP07)
26
+
27
+ - **No "Best known for" column.** Each row's headline count (works shipped, pages approved, thoughts ignited) is a per-builder read in three different modules; the list would pay three more reads for every page of builders. The profile, one click away, leads with exactly that number.
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
+ - **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
+ - **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.
@@ -944,6 +944,29 @@ async function runPromotionSweep(deps = {}) {
944
944
  }
945
945
  }
946
946
 
947
+ // The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
948
+ // migration cannot backfill, "A Whole Surface", needs the page inventory, which is
949
+ // a file the database never sees. Run ONCE per process start, on the first tick,
950
+ // through the reward port (economy is optional; no port, no sweep). Pure DB and
951
+ // idempotent, so a restart that runs it again unlocks nothing twice; a failure is
952
+ // logged and never touches the other sweeps.
953
+ let craftBackfillDone = false;
954
+ async function runCraftAchievementBackfill(deps = {}) {
955
+ const { logFn, errFn } = loggers(deps);
956
+ if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
957
+ craftBackfillDone = true;
958
+ const reward = deps.reward || seams.resolveOptional('reward');
959
+ if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
960
+ try {
961
+ const out = await reward.backfillCraftAchievements({});
962
+ if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
963
+ return out;
964
+ } catch (e) {
965
+ errFn(`[publish-reconciler] craft achievement backfill (non-blocking): ${e && e.message}`);
966
+ return { skipped: 'error', error: e && e.message };
967
+ }
968
+ }
969
+
947
970
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
948
971
  // by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
949
972
  // default whenever a push credential is configured; the route's getPublishStatus
@@ -964,6 +987,7 @@ function start(deps = {}) {
964
987
  runCostSweep(deps).catch(crashHandler('cost sweep'));
965
988
  runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
966
989
  runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
990
+ runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
967
991
  };
968
992
 
969
993
  firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
@@ -1002,6 +1026,7 @@ module.exports = {
1002
1026
  runCostSweep,
1003
1027
  runPromotionSweep,
1004
1028
  runCriterionSweep,
1029
+ runCraftAchievementBackfill,
1005
1030
  couldBeStaleGenerated,
1006
1031
  describeTarget,
1007
1032
  INTERVAL_MS,
@@ -5,16 +5,21 @@
5
5
  // Kept beside github-push.js rather than in it: it shares that file's credential and headers,
6
6
  // and nothing else.
7
7
 
8
- const { resolveToken, ghHeaders, isSafeBranch } = require('./github-push');
8
+ const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
9
9
  const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
10
10
 
11
11
  // dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
12
12
  // Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
13
- // leaves GitHub: the server holds only the GitHub App key it already holds, and the App needs
14
- // Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
15
- // { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT, REFUSED
16
- // (GitHub said no: 403 = the App lacks Actions: write, 404 = no such workflow or no access,
17
- // 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
13
+ // leaves GitHub: the server uses only the GitHub credential it already holds, which needs
14
+ // Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
15
+ // { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
16
+ // REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
17
+ // access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
18
+ //
19
+ // `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
20
+ // GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
21
+ // (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
22
+ // owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
18
23
  //
19
24
  // ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
20
25
  // another module; an allowlist keeps a buggy or future borrower from running any other workflow in
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
22
27
  const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
23
28
  async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
24
29
  if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
25
- let token;
26
- try { token = await resolveToken(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED' }; }
30
+ let token = deps.token || null;
31
+ let credential = token ? (deps.credential || 'token') : null;
32
+ if (!token) {
33
+ try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
34
+ if (token) credential = 'app';
35
+ }
36
+ if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
27
37
  if (!token) return { ok: false, code: 'UNCONFIGURED' };
28
38
  const repoInfo = deps.repoInfo || loadRepoInfo();
29
39
  if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
35
45
  headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
36
46
  body: JSON.stringify({ ref, inputs }),
37
47
  });
38
- if (res.status === 204 || res.ok) return { ok: true };
39
- return { ok: false, code: 'REFUSED', status: res.status };
48
+ if (res.status === 204 || res.ok) return { ok: true, credential };
49
+ return { ok: false, code: 'REFUSED', status: res.status, credential };
40
50
  } catch (_) {
41
- return { ok: false, code: 'UNREACHABLE' };
51
+ return { ok: false, code: 'UNREACHABLE', credential };
42
52
  }
43
53
  }
44
54
 
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
95
95
  }
96
96
  }
97
97
 
98
+ // The credential a refusal names, in the words of the settings page the owner must open: GitHub
99
+ // lists an App under Developer settings → GitHub Apps, and an access token under Developer
100
+ // settings → Personal access tokens (task 1004518).
101
+ function credentialName(credential) {
102
+ if (credential === 'app') return "the server's GitHub App";
103
+ if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
104
+ return "the server's GitHub credential";
105
+ }
106
+
98
107
  /**
99
108
  * What the Release button does. Returns { status, body } for the route to send.
100
109
  *
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
118
127
  }
119
128
  const why = !r ? 'no answer'
120
129
  : r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
121
- : r.code === 'REFUSED' && r.status === 403 ? "GitHub refused: the server's GitHub App needs the Actions: write permission"
130
+ : r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
122
131
  : r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
123
132
  : r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
124
133
  : r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
4
+ // 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
5
+ // D4, D8, D9).
6
+ //
7
+ // THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
8
+ // through the startup flow opened as an ordinary hall. The create request lands on the
9
+ // HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
10
+ // holds no way into that database. So the hub only says so, and the instance acts:
11
+ //
12
+ // 1. The hub's create route stamps a reserved key on the new row
13
+ // (modules/provisioning/birth.js) — never on a row that already exists.
14
+ // 2. The runner writes it into the instance's web.env with the other settings
15
+ // companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
16
+ // when the row is a demo. src/branding.js maps them onto `founding.*`, which is
17
+ // server-only (clientBranding never publishes it).
18
+ // 3. Here, the instance reads that marker and, once, starts founding — and for a
19
+ // demo, also settles the expedited plan, so its decide stage needs no choices.
20
+ //
21
+ // WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
22
+ // GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
23
+ // founder's first sign-in, so the hall opens already in genesis; and by then the
24
+ // founder is seated and the kickoff seed has run, so the board the demo's stages are
25
+ // filed on exists. At boot it would race the module load order (the lifecycle port the
26
+ // plan files tasks through may not be registered yet) and a database that is still
27
+ // coming up.
28
+ //
29
+ // WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
30
+ // * No marker, no run: a project created before this shipped has none (D8), and a
31
+ // marker-less read resolves without touching the database.
32
+ // * Once per process: one shared promise, so two concurrent first reads share one run.
33
+ // * Once for good: founding is started only when it was never started and never
34
+ // ended (founding.started_at / ended_at), so a restart, a later settings push that
35
+ // rewrites the env, or an owner who already started by hand changes nothing. A demo
36
+ // carried over keeps its state for the same reason: genesis happened once.
37
+ // * The demo's plan is saved only while the project is still deciding with no plan,
38
+ // and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
39
+ // that skips a step already filed.
40
+
41
+ const fm = require('./founding-mode');
42
+ const genesis = require('./genesis-home');
43
+
44
+ // The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
45
+ const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
46
+
47
+ // birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
48
+ // this project was born through the startup flow, and its demo time box if a demo. A
49
+ // malformed demo value reads as no demo (the project still founds, with the normal plan).
50
+ function birthMarker(founding) {
51
+ const f = founding && typeof founding === 'object' ? founding : {};
52
+ const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
53
+ if (!born) return { born: false, demo: null };
54
+ const people = Number(f.demoPeople);
55
+ const hours = Number(f.demoHours);
56
+ const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
57
+ return { born: true, demo };
58
+ }
59
+
60
+ function markerFrom(deps) {
61
+ if (deps.marker) return deps.marker;
62
+ let b;
63
+ try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
64
+ return birthMarker(b && b.founding);
65
+ }
66
+
67
+ // bornFounding(deps) — the guarded birth. Returns what it did:
68
+ // { ran: false, reason } nothing to do (no marker, already, alive)
69
+ // { ran: true, started, plan, demo } it started founding and/or saved the plan
70
+ // { ran: false, reason, retry: true } the demo plan could not be saved yet
71
+ async function bornFounding(deps = {}) {
72
+ const marker = markerFrom(deps);
73
+ if (!marker.born) return { ran: false, reason: 'no_marker' };
74
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
75
+ const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
76
+ if (ended && ended.value) return { ran: false, reason: 'already_alive' };
77
+ let didStart = false;
78
+ if (!(started && started.value)) {
79
+ const r = await genesis.startFounding(null, deps);
80
+ if (!r.ok) return { ran: false, reason: r.reason };
81
+ didStart = true;
82
+ }
83
+ if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
84
+ // The demo's expedited plan: only while still deciding, and only if none is saved.
85
+ const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
86
+ const planRow = await get(genesis.PLAN_KEY);
87
+ if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
88
+ return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
89
+ }
90
+ const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
91
+ if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
92
+ return { ran: true, started: didStart, plan: true, demo: true };
93
+ }
94
+
95
+ // ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
96
+ // concurrent callers; forgets a run that failed or asked to retry, so a later read tries
97
+ // again. Never throws: a birth hiccup must never fail the read that triggered it.
98
+ let once = null;
99
+ function ensureBirth(deps = {}) {
100
+ if (once) return once;
101
+ const p = bornFounding(deps).then((r) => {
102
+ if (r && r.retry) once = null;
103
+ return r;
104
+ }, (err) => {
105
+ once = null;
106
+ try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
107
+ return { ran: false, reason: 'error' };
108
+ });
109
+ once = p;
110
+ return p;
111
+ }
112
+
113
+ // Test seam: forget the per-process run.
114
+ function resetBirthForTests() { once = null; }
115
+
116
+ module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };