@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
@@ -0,0 +1,155 @@
1
+ // scripts/gds/session-transcript-build.js — the PURE transcript→full-transcript builder.
2
+ //
3
+ // ADR 0051 / task 1001004: every session is stored as its FULL per-turn content
4
+ // (user prompts, assistant text, tool calls AND their results), not just the
5
+ // metadata + 160-char snippets buildSessionDigest keeps. This is the sibling of
6
+ // session-digest-build.js and shares its scrubber: EVERY string leaves this file
7
+ // through scrubSecrets (modules/security/secret-scrub.js, the task-1003 scrubber),
8
+ // on the builder's box, before anything is uploaded. A string that reaches the
9
+ // output unscrubbed is a defect — keep that single choke point (`clean`).
10
+ //
11
+ // Bounded on purpose: each text field is capped and the whole transcript has a
12
+ // byte ceiling (head + tail kept, like buildSessionDigest's turn cap), so one
13
+ // runaway tool result cannot make an upload that the server must reject.
14
+ // Extended-thinking blocks are NOT captured: they are model-internal, often
15
+ // opaque, and ADR 0051 asks for what the builder asked and what they were told.
16
+ const { scrubSecrets } = require('../../modules/security/secret-scrub');
17
+
18
+ const MAX_FIELD_CHARS = 20000; // one prompt / reply / tool result / tool input
19
+ const MAX_TRANSCRIPT_BYTES = 12 * 1024 * 1024; // whole upload ceiling (the server enforces the same number)
20
+ const MAX_TRANSCRIPT_TURNS = 20000; // the route's maxItems; the builder trims to it so the server never has to refuse
21
+ const MAX_TURN_BYTES = 200 * 1024; // under the route's 256 KB per-turn cap, so a many-tool-call turn is shrunk here rather than refused there
22
+ const MAX_RAW_CHARS = 200000; // bound on what the scrubber is ever handed (entropy-sweep cost)
23
+
24
+ // clean — the one exit for every string: drop NULs (Postgres rejects them in
25
+ // jsonb), SCRUB, then cap. Scrub BEFORE the final cap so a credential straddling
26
+ // the cap boundary is redacted whole rather than sliced into a fragment too short
27
+ // for any scrub shape to recognise. The scrubber is only ever handed MAX_RAW_CHARS
28
+ // so a multi-MB tool result cannot make the entropy sweep unbounded.
29
+ function clean(text, extraSecrets) {
30
+ if (typeof text !== 'string' || text.length === 0) return undefined;
31
+ const flat = text.replace(/\0/g, '');
32
+ if (!flat) return undefined;
33
+ const bounded = flat.length > MAX_RAW_CHARS ? flat.slice(0, MAX_RAW_CHARS) : flat;
34
+ const scrubbed = scrubSecrets(bounded, extraSecrets);
35
+ if (!scrubbed) return undefined;
36
+ if (scrubbed.length <= MAX_FIELD_CHARS && flat.length === bounded.length) return scrubbed;
37
+ const omitted = (flat.length - bounded.length) + Math.max(0, scrubbed.length - MAX_FIELD_CHARS);
38
+ return `${scrubbed.slice(0, MAX_FIELD_CHARS)}…[+${omitted} chars]`;
39
+ }
40
+
41
+ function resultText(content) {
42
+ if (typeof content === 'string') return content;
43
+ if (Array.isArray(content)) {
44
+ return content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).filter(Boolean).join('\n');
45
+ }
46
+ return '';
47
+ }
48
+
49
+ // shrinkTurn — cut every string field of an oversize turn to an even share of a
50
+ // fixed character budget (in place), marking the cut. Already scrubbed, so this
51
+ // only ever removes text.
52
+ function shrinkTurn(t) {
53
+ const fields = [];
54
+ if (typeof t.text === 'string') fields.push([t, 'text']);
55
+ for (const c of t.tool_calls || []) if (typeof c.input === 'string') fields.push([c, 'input']);
56
+ for (const r of t.tool_results || []) if (typeof r.text === 'string') fields.push([r, 'text']);
57
+ const cap = Math.max(200, Math.floor(40000 / Math.max(1, fields.length)));
58
+ for (const [o, k] of fields) {
59
+ if (o[k].length > cap) o[k] = `${o[k].slice(0, cap)}…[+${o[k].length - cap} chars]`;
60
+ }
61
+ }
62
+
63
+ // buildSessionTranscript — parsed Claude Code transcript entries →
64
+ // { turns, turnCount, byteSize, truncated }. Pure; exported for tests.
65
+ function buildSessionTranscript(entries, opts = {}) {
66
+ const extra = opts.extraSecrets || [];
67
+ const maxBytes = opts.maxBytes || MAX_TRANSCRIPT_BYTES;
68
+ const maxTurns = opts.maxTurns || MAX_TRANSCRIPT_TURNS;
69
+ const turns = [];
70
+ let i = 0;
71
+ for (const e of Array.isArray(entries) ? entries : []) {
72
+ if (!e || typeof e !== 'object' || !e.message) continue;
73
+ const ts = typeof e.timestamp === 'string' ? e.timestamp : undefined;
74
+ const msg = e.message;
75
+ if (e.type === 'assistant') {
76
+ const row = { i, role: 'assistant', ts };
77
+ const texts = [];
78
+ const calls = [];
79
+ for (const c of Array.isArray(msg.content) ? msg.content : []) {
80
+ if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
81
+ else if (c && c.type === 'tool_use') {
82
+ let input;
83
+ try { input = clean(JSON.stringify(c.input), extra); } catch (_) { input = undefined; }
84
+ calls.push({ name: String(c.name || '?'), input });
85
+ }
86
+ }
87
+ const text = clean(texts.join('\n'), extra);
88
+ if (text) row.text = text;
89
+ if (calls.length) row.tool_calls = calls;
90
+ if (!text && !calls.length) continue; // pure-thinking line: nothing readable
91
+ turns.push(row);
92
+ i++;
93
+ } else if (e.type === 'user') {
94
+ const row = { i, role: 'user', ts };
95
+ if (typeof msg.content === 'string') {
96
+ const text = clean(msg.content, extra);
97
+ if (text) row.text = text;
98
+ } else if (Array.isArray(msg.content)) {
99
+ const texts = [];
100
+ const results = [];
101
+ for (const c of msg.content) {
102
+ if (c && c.type === 'text' && typeof c.text === 'string') texts.push(c.text);
103
+ else if (c && c.type === 'tool_result') {
104
+ const r = { text: clean(resultText(c.content), extra) };
105
+ if (c.is_error) r.is_error = true;
106
+ results.push(r);
107
+ }
108
+ }
109
+ const text = clean(texts.join('\n'), extra);
110
+ if (text) row.text = text;
111
+ if (results.length) row.tool_results = results;
112
+ }
113
+ if (!row.text && !row.tool_results) continue;
114
+ turns.push(row);
115
+ i++;
116
+ }
117
+ }
118
+
119
+ // Byte ceiling: keep the head (what was asked) and the tail (how it ended),
120
+ // drop the middle, and SAY so — a silently shortened record reads as complete.
121
+ // Serialize each turn once; an oversize turn (many tool calls, each at the field
122
+ // cap) is shrunk field-by-field to fit, so the route's per-turn cap never refuses
123
+ // a transcript the builder could have trimmed. Sizes include the comma each turn
124
+ // costs in the array, and the total the 2 bytes of brackets, so this total is the
125
+ // SAME number the route measures against its ceiling.
126
+ const sizes = turns.map((t, idx) => {
127
+ let json = JSON.stringify(t);
128
+ let bytes = Buffer.byteLength(json, 'utf8');
129
+ if (bytes > MAX_TURN_BYTES) {
130
+ shrinkTurn(t);
131
+ json = JSON.stringify(t);
132
+ bytes = Buffer.byteLength(json, 'utf8');
133
+ }
134
+ turns[idx] = t;
135
+ return bytes + 1;
136
+ });
137
+ let total = sizes.reduce((a, b) => a + b, 2);
138
+ let out = turns;
139
+ let truncated = false;
140
+ if (total > maxBytes || turns.length > maxTurns) {
141
+ truncated = true;
142
+ const headBudget = Math.floor(maxBytes * 0.6);
143
+ const tailBudget = maxBytes - headBudget;
144
+ let h = 0; let headEnd = 0;
145
+ const headCount = Math.floor(maxTurns * 0.6);
146
+ while (headEnd < turns.length && headEnd < headCount && h + sizes[headEnd] <= headBudget) h += sizes[headEnd++];
147
+ let t = 0; let tailStart = turns.length;
148
+ while (tailStart > headEnd && (turns.length - tailStart) < maxTurns - headEnd && t + sizes[tailStart - 1] <= tailBudget) t += sizes[--tailStart];
149
+ out = turns.slice(0, headEnd).concat(turns.slice(tailStart));
150
+ total = h + t + 2;
151
+ }
152
+ return { turns: out, turnCount: turns.length, byteSize: total, truncated };
153
+ }
154
+
155
+ module.exports = { buildSessionTranscript, MAX_FIELD_CHARS, MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_TURNS };
@@ -0,0 +1,29 @@
1
+ // scripts/gds/session-transcript-upload.js — send a session's FULL scrubbed
2
+ // transcript to the corpus (ADR 0051, task 1001004).
3
+ //
4
+ // Shared by the SessionEnd hook (session-digest.js — fires for EVERY session) and
5
+ // the on-ship upload (ship-session-upload.js — covers the mid-session tail), so
6
+ // both build and send the transcript the same way. BEST-EFFORT and NON-FATAL like
7
+ // the digest upload beside it: returns a one-line outcome string, never throws.
8
+ // It must run AFTER the digest POST — the server creates the transcript row only
9
+ // from the caller's own session_records row.
10
+ const { buildSessionTranscript } = require('./session-transcript-build');
11
+
12
+ async function uploadSessionTranscript(api, sessionId, entries, extraSecrets) {
13
+ try {
14
+ const t = buildSessionTranscript(entries, { extraSecrets });
15
+ if (!t.turns.length) return 'transcript: nothing to store.';
16
+ const r = await api.sessions.putSessionsSidTranscript({
17
+ sid: sessionId,
18
+ body: { turns: t.turns, truncated: t.truncated },
19
+ });
20
+ if (r && r.ok) {
21
+ return `transcript: stored ${t.turns.length}/${t.turnCount} turns (${Math.round(t.byteSize / 1024)} KB${t.truncated ? ', truncated to the size ceiling' : ''}).`;
22
+ }
23
+ return `transcript: skipped (PUT ${r && r.status}${r && r.data && r.data.error ? ' ' + JSON.stringify(r.data.error) : ''}).`;
24
+ } catch (e) {
25
+ return `transcript: skipped — ${e && e.message ? e.message : e}`;
26
+ }
27
+ }
28
+
29
+ module.exports = { uploadSessionTranscript };
@@ -13,6 +13,7 @@ const path = require('node:path');
13
13
  const { cliClient, loadSession } = require('./cli-lib');
14
14
  const { resolveTranscriptPath } = require('./ship-memory-sync.js');
15
15
  const { apiErrorLine } = require('./ship-io.js');
16
+ const { uploadSessionTranscript } = require('./session-transcript-upload.js');
16
17
  const { REPO_ROOT } = require('./ship-state.js');
17
18
 
18
19
  // ---------- Session upload-on-ship (6D.1 / ADR 0027) ----------
@@ -268,11 +269,15 @@ async function uploadSessionOnShip(taskId) {
268
269
  metrics: digest.metrics,
269
270
  model_usage: digest.metrics.model_usage || {}, // per-model tokens → cost-plus reward (task 1005)
270
271
  };
271
- const r = await (await cliClient({ allowUnauthenticated: true })).sessions.postSessions({ body: payload });
272
+ const api = await cliClient({ allowUnauthenticated: true });
273
+ const r = await api.sessions.postSessions({ body: payload });
272
274
  if (r.ok) {
273
275
  console.log(
274
276
  ` session upload: stored digest (${digest.metrics.assistant_turns} assistant turns, ${digest.totalTokens} tokens, ${digest.metrics.tool_error_count} tool errors, ${digest.metrics.harness_retry_count} harness retries).`
275
277
  );
278
+ // ADR 0051: the full scrubbed transcript rides after the digest (the server
279
+ // creates it from the digest's row). Non-fatal — its outcome is a log line.
280
+ console.log(` session upload: ${await uploadSessionTranscript(api, sessionId, entries, extraSecrets)}`);
276
281
  } else if (r.status === 401) {
277
282
  recordUploadSkip(taskId, {
278
283
  code: 'session_invalid',
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/upgrade-commit-identity.js — WHO the pin commit is signed by when the project's
4
+ // git names nobody (task 1004514). Kept out of upgrade.js, which sits at the 1500-line cap.
5
+ //
6
+ // THE BUG. persistPin staged the new pin and ran `git commit`. In a repo with no
7
+ // user.name/user.email — none in the repo, none global for the account the upgrade runs as —
8
+ // git refuses ("Please tell me who you are"), so the move reported success with the pin
9
+ // STAGED but never committed. The next move then read its own leftovers as a dirty tree and
10
+ // spent its one automatic fix (wedge-remedy.js) committing them. Seen live on 2026-10-01: a
11
+ // throwaway hosted project on cloudbongos.com, and hermeslines-marketing, whose repo carries no
12
+ // identity while every scaffolded one does (provision-repo.js sets it at scaffold time).
13
+ //
14
+ // THE FIX IS A FALLBACK, NEVER AN OVERRIDE. A repo or account that names someone keeps
15
+ // signing as them. Only an empty answer gets the provisioner's own identity, the one the
16
+ // scaffold commits as, through git's AUTHOR/COMMITTER env — so nothing is written into the
17
+ // owner's git config, and the argv stays exactly what it was.
18
+
19
+ /**
20
+ * Env for `git commit` in `instanceDir`: null when git already names a committer there, else
21
+ * the provisioner's identity. Asks git itself, so repo, global and system config all count.
22
+ */
23
+ function commitIdentityEnv(instanceDir, run, env = process.env) {
24
+ const r = run('git', ['config', 'user.email'], { cwd: instanceDir, encoding: 'utf8' });
25
+ if (r && !r.error && r.status === 0 && String(r.stdout || '').trim()) return null;
26
+ if (env.GIT_AUTHOR_EMAIL || env.GIT_COMMITTER_EMAIL) return null; // the caller already chose
27
+ const { provisionerBotName, provisionerBotEmail } = require('./instance-apex.js');
28
+ const name = provisionerBotName(), email = provisionerBotEmail(env);
29
+ return { ...env, GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email };
30
+ }
31
+
32
+ module.exports = { commitIdentityEnv };
@@ -352,7 +352,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
352
352
  err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
353
353
  return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
354
354
  }
355
- const commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8' });
355
+ const idEnv = require('./upgrade-commit-identity').commitIdentityEnv(instanceDir, run), commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8', ...(idEnv ? { env: idEnv } : {}) }); // a repo naming nobody still commits (task 1004514)
356
356
  if (!commit || commit.error || commit.status !== 0) {
357
357
  err(' ! could not commit the pin — commit + push it by hand or the next deploy reverts this bump.');
358
358
  return { applicable: true, committed: false, pushed: false, dirty, error: 'git commit failed' };
@@ -76,6 +76,8 @@ const SNAPSHOTS = [
76
76
  ['public/grades?days=7', 'public__grades'],
77
77
  ['public/repo-health?days=30', 'public__repo-health'],
78
78
  ['public/bounty-table', 'public__bounty-table'],
79
+ // the roster and the Standing card read the public list, with its roles (task 1004437)
80
+ ['public/leaderboard', 'public__leaderboard'],
79
81
  ['public/cost-summary', 'public__cost-summary'],
80
82
  ['public/progress', 'public__progress'],
81
83
  ['public/recent-shipped', 'public__recent-shipped'],
package/src/bongos/db.js CHANGED
@@ -207,6 +207,18 @@ async function getBuilderRoleChoice(builderId) {
207
207
  return rows[0] ?? null;
208
208
  }
209
209
 
210
+ // The same two facts for a whole list of builders, in ONE statement (task
211
+ // 1004437): the public leaderboard names every row's roles. Map<idString, row>.
212
+ async function getBuilderRoleChoices(builderIds) {
213
+ const ids = (builderIds || []).map(String).filter((v) => /^[1-9]\d*$/.test(v));
214
+ if (!ids.length) return new Map();
215
+ const { rows } = await pool.query(
216
+ `SELECT id, preferred_disciplines, main_discipline FROM builders WHERE id = ANY($1::bigint[])`,
217
+ [ids]
218
+ );
219
+ return new Map(rows.map((r) => [String(r.id), r]));
220
+ }
221
+
210
222
  // The builder-PREFS db slice — the five per-builder preference jsonb columns on
211
223
  // `builders` (skill_model_prefs / sound_prefs / behavior_prefs / event_sound_prefs)
212
224
  // — was carved into modules/builder-settings/db.js (BV1.R80 / ADR 0093 §1). Core
@@ -1103,6 +1115,7 @@ module.exports = {
1103
1115
  updateBuilderDisciplines,
1104
1116
  UPDATE_BUILDER_DISCIPLINES_SQL,
1105
1117
  getBuilderRoleChoice,
1118
+ getBuilderRoleChoices,
1106
1119
  RANKS,
1107
1120
  setBuilderRank,
1108
1121
  getLatestRankChangeForBuilder,
@@ -149,7 +149,41 @@ async function readRoleStats(builderId, { shippedCount = 0, getRoleChoice } = {}
149
149
  return composeRoleStats({ choice, byCraft, shippedCount, artist, ideator });
150
150
  }
151
151
 
152
- // Only the reader is exported: the pure halves above are reached through it
152
+ // readRolesForBuilders — the same main/shown rules for a WHOLE LIST at once
153
+ // (task 1004437 / WA6.RP07): the public leaderboard names each builder's roles
154
+ // and ranks a role by the credits earned in it, so every row needs its `shown`
155
+ // crafts and its ledger split. Two reads for the whole page of rows, never one
156
+ // per row: the role choices (`getRoleChoices`, one statement) and economy's
157
+ // `creditsByCraft`, which already batches. Returns Map<idString, { main,
158
+ // main_basis, shown, credits_by_craft }>, every id asked for present. Counts and
159
+ // sums only, as the profile's tally is: nothing here is behind a sign-in.
160
+ async function readRolesForBuilders(builderIds, { getRoleChoices } = {}) {
161
+ const ids = [...new Set((builderIds || []).map(String).filter((v) => /^[1-9]\d*$/.test(v)))];
162
+ const out = new Map();
163
+ if (!ids.length) return out;
164
+ const reward = seams.resolveOptional('reward');
165
+ const [choices, byCraftMap] = await Promise.all([
166
+ getRoleChoices ? getRoleChoices(ids) : new Map(),
167
+ reward && typeof reward.creditsByCraft === 'function' ? reward.creditsByCraft(ids) : null,
168
+ ]);
169
+ for (const id of ids) {
170
+ const choice = (choices && choices.get(id)) || null;
171
+ const byCraft = byCraftMap ? (byCraftMap.get(id) || null) : null;
172
+ const preferred = (choice && choice.preferred_disciplines) || [];
173
+ const { main, basis } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
174
+ out.set(id, {
175
+ main,
176
+ main_basis: basis,
177
+ shown: shownCrafts({ main, preferred, byCraft }),
178
+ credits_by_craft: byCraft
179
+ ? { engineer: byCraft.engineer, artist: byCraft.artist, ideator: byCraft.ideator, other: byCraft.other, total: byCraft.total }
180
+ : null,
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // Only the readers are exported: the pure halves above are reached through them
153
187
  // (tests/profile_role_stats.mjs drives them via the real route), and an export
154
188
  // nothing imports is dead surface to the knip ratchet.
155
- module.exports = { readRoleStats };
189
+ module.exports = { readRoleStats, readRolesForBuilders };
@@ -37,6 +37,7 @@ const { clientBranding, branding } = require('../../branding');
37
37
  const { clientModules } = require('../../modules');
38
38
  const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
39
39
  const { parseId, corsPublicGet, parsePagination, pageMeta } = require('./_helpers');
40
+ const { readRolesForBuilders } = require('../role-stats');
40
41
  // task 1003208: structured logging — was console.*. A core file logs through
41
42
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
42
43
  const log = require('../logger').logger.child({ src: 'public' });
@@ -235,9 +236,19 @@ module.exports = function buildPublicRouter() {
235
236
  // 100 preserves the prior ceiling. `page` is additive (readers of .leaderboard
236
237
  // unchanged). Mirrors the authed /leaderboard.
237
238
  const { limit, offset } = parsePagination(req, { defaultLimit: 100, maxLimit: 200 });
238
- const rows = await seams.resolveOptional('reward').getLeaderboardWithAchievements(offset + limit, 5);
239
+ const rows = (await seams.resolveOptional('reward').getLeaderboardWithAchievements(offset + limit, 5)).slice(offset, offset + limit);
240
+ // task 1004437 (WA6.RP07): each row names its roles, main first, and carries
241
+ // the credits each craft earned, so a role filter ranks by a figure the row
242
+ // itself publishes (ADR 0255). Two reads for the page, not one per row. A
243
+ // failure costs the roles, never the board: the rows still go out.
244
+ try {
245
+ const roles = await readRolesForBuilders(rows.map((r) => r.id), { getRoleChoices: db.getBuilderRoleChoices });
246
+ for (const r of rows) r.roles = roles.get(String(r.id)) || null;
247
+ } catch (err) {
248
+ log.error({ err }, 'leaderboard roles failed');
249
+ }
239
250
  res.set('Cache-Control', 'public, max-age=60');
240
- res.json({ leaderboard: rows.slice(offset, offset + limit), page: pageMeta(limit, offset) });
251
+ res.json({ leaderboard: rows, page: pageMeta(limit, offset) });
241
252
  });
242
253
 
243
254
  // rank: public — est-vs-actual aggregate.
@@ -93,6 +93,9 @@ function buildGdsRouter() {
93
93
  if (
94
94
  req.path === '/memory/sync' ||
95
95
  req.path === '/sessions' ||
96
+ // ADR 0051 / task 1001004: the full-transcript upload (MB-scale), which
97
+ // mounts its own 16 MB parser in modules/sessions/routes/sessions.js.
98
+ /^\/sessions\/[A-Za-z0-9_-]+\/transcript$/.test(req.path) ||
96
99
  req.path === '/llm-cache/store' ||
97
100
  /^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
98
101
  // task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
package/src/branding.js CHANGED
@@ -94,6 +94,15 @@ const ENV_OVERRIDES = [
94
94
  ['NIGHT_TINT', ['project', 'nightTint']],
95
95
  ['LOOK_ACCENT', ['project', 'lookAccent']],
96
96
  ['LOGO_URL', ['project', 'logoUrl']],
97
+ // The birth marker (task 1004506): set by the platform only on a project created
98
+ // through the startup flow, so a project made before that never has it (spec D8).
99
+ // FOUNDING_BIRTH is when the platform made the row; DEMO_PEOPLE / DEMO_HOURS are the
100
+ // demo's time box, empty for a real project. The onboarding module reads them once to
101
+ // start genesis (modules/onboarding/founding-birth.js). SERVER-ONLY: clientBranding
102
+ // publishes no `founding` block.
103
+ ['FOUNDING_BIRTH', ['founding', 'birth']],
104
+ ['DEMO_PEOPLE', ['founding', 'demoPeople']],
105
+ ['DEMO_HOURS', ['founding', 'demoHours']],
97
106
  ];
98
107
 
99
108
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -100,6 +100,19 @@ function fakeClient({ eligible = new Set(), unlocked = new Set(), credits = 0 })
100
100
  if (s.includes("reason = 'ship.net_negative'")) {
101
101
  return { rows: [{ n: eligible.has('subtractor') ? 3 : 0 }], rowCount: 1 };
102
102
  }
103
+ // the craft achievements (task 1004438): ONE facts read answers all seven
104
+ if (s.startsWith('select bool_or(reason like $2) as ignited')) {
105
+ return {
106
+ rows: [{
107
+ ignited: eligible.has('first-thought-ignited'),
108
+ full_idea: eligible.has('first-full-idea'),
109
+ ratified: eligible.has('first-board-ratification'),
110
+ pages: eligible.has('ten-pages-approved') ? 10 : eligible.has('first-page-approved') ? 1 : 0,
111
+ best_thought_tasks: eligible.has('thought-built-ten') ? 10 : 0,
112
+ }],
113
+ rowCount: 1,
114
+ };
115
+ }
103
116
  return no;
104
117
  });
105
118