@bongos/core 1.20.58 → 1.20.60

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 (119) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -2
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +317 -6
  10. package/docs/api-reference.md +8 -3
  11. package/docs/architecture.md +1 -0
  12. package/docs/copy-inventory.md +339 -330
  13. package/docs/copy-registry.json +524 -439
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +4 -1
  16. package/docs/page-readings.json +729 -678
  17. package/migrations/core_268_builders_main_discipline.sql +35 -0
  18. package/modules/copy-desk/artist-stats.js +56 -0
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +39 -0
  21. package/modules/copy-desk/routes/copy-desk.js +7 -16
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  23. package/modules/economy/credits-by-craft.js +208 -0
  24. package/modules/economy/reward.js +8 -0
  25. package/modules/hall-ui/public/atlas.html +1 -1
  26. package/modules/hall-ui/public/blockers.html +1 -1
  27. package/modules/hall-ui/public/board-room.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.html +1 -1
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goals.html +1 -1
  38. package/modules/hall-ui/public/government.html +1 -1
  39. package/modules/hall-ui/public/hall-render.js +2 -2
  40. package/modules/hall-ui/public/idea.html +1 -1
  41. package/modules/hall-ui/public/ideas.html +1 -1
  42. package/modules/hall-ui/public/index.html +1 -1
  43. package/modules/hall-ui/public/modules.html +1 -1
  44. package/modules/hall-ui/public/primer.html +1 -1
  45. package/modules/hall-ui/public/profile.html +1 -1
  46. package/modules/hall-ui/public/profile.js +1 -1
  47. package/modules/hall-ui/public/project-settings.html +1 -1
  48. package/modules/hall-ui/public/ranks.html +1 -1
  49. package/modules/hall-ui/public/roadmap.html +1 -1
  50. package/modules/hall-ui/public/roster.html +1 -1
  51. package/modules/hall-ui/public/sessions.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +5 -1
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/style.css +8 -0
  56. package/modules/hall-ui/public/task.html +1 -1
  57. package/modules/hall-ui/public/thinking.html +1 -1
  58. package/modules/hall-ui/public/tweak-editor.html +1 -1
  59. package/modules/hall-ui/public/watch.html +1 -1
  60. package/modules/hall-ui/public/work.html +1 -1
  61. package/modules/ideas/ideator-stats.js +149 -0
  62. package/modules/ideas/module.json +2 -1
  63. package/modules/ideas/routes/inbox.js +7 -0
  64. package/modules/lifecycle/activity.js +5 -2
  65. package/modules/lifecycle/db-analytics.js +4 -2
  66. package/modules/lifecycle/db-tasks.js +49 -1
  67. package/modules/lifecycle/db.js +3 -2
  68. package/modules/lifecycle/page-tweak-reads.js +23 -5
  69. package/modules/provisioning/look.js +169 -0
  70. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  71. package/modules/provisioning/module.json +2 -1
  72. package/modules/provisioning/provisioning.js +4 -3
  73. package/modules/provisioning/routes/look.js +204 -0
  74. package/modules/provisioning/routes/provisioning.js +5 -59
  75. package/modules/provisioning/settings-push.js +76 -0
  76. package/modules/public-landing/public/assets/cosmos.css +1 -1
  77. package/modules/public-landing/public/projects.html +499 -28
  78. package/modules/public-landing/public/projects.probes.json +2 -2
  79. package/modules/public-landing/public/projects.states.json +6 -3
  80. package/modules/ui-design/kit/serve.js +50 -0
  81. package/package-lock.json +2 -2
  82. package/package.json +1 -1
  83. package/release-notes.json +12 -0
  84. package/scripts/gds/fitness.js +4 -0
  85. package/scripts/gds/run-unit-tests.js +5 -0
  86. package/src/bongos/db.js +44 -6
  87. package/src/bongos/role-stats.js +155 -0
  88. package/src/bongos/routes/builders.js +15 -2
  89. package/src/bongos/routes/me.js +11 -3
  90. package/src/bongos/routes.js +4 -1
  91. package/src/bongos/serve-internal.js +6 -0
  92. package/src/branding.js +26 -1
  93. package/src/hall-look.js +99 -0
  94. package/src/module-api.js +1 -1
  95. package/src/night-tint.js +26 -0
  96. package/tests/copy_desk_page_reads.mjs +1 -1
  97. package/tests/currency_label.mjs +7 -4
  98. package/tests/fixtures/page-ledger.mjs +1 -1
  99. package/tests/hall_tokens.mjs +10 -1
  100. package/tests/hall_tweak_editor.mjs +1 -1
  101. package/tests/look_night_tint.mjs +169 -0
  102. package/tests/main_discipline.mjs +81 -0
  103. package/tests/nav_permission_atoms.mjs +1 -1
  104. package/tests/profile_role_stats.mjs +416 -0
  105. package/tests/projects_hub.mjs +5 -4
  106. package/tests/projects_hub_app_status.mjs +1 -1
  107. package/tests/projects_hub_app_step.mjs +8 -7
  108. package/tests/projects_hub_look.mjs +255 -0
  109. package/tests/projects_hub_module_picker.mjs +2 -2
  110. package/tests/provision_settings_apply.mjs +12 -4
  111. package/tests/provisioning_look.mjs +376 -0
  112. package/tests/provisioning_settings.mjs +10 -7
  113. package/tests/provisioning_settings_apply.mjs +4 -4
  114. package/tests/provisioning_settings_env.mjs +4 -0
  115. package/tests/role_stats_db.mjs +237 -0
  116. package/tests/tweak_replaces_artist_review.mjs +1 -1
  117. package/tests/wizard_draft_resume.mjs +2 -2
  118. package/tests/wizard_front_door.mjs +2 -2
  119. package/tests/wizard_intent_resume.mjs +16 -15
@@ -101,7 +101,7 @@
101
101
 
102
102
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
103
103
 
104
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
104
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
105
105
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
106
106
  <script src="/builders/palette.js" defer></script>
107
107
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
@@ -115,7 +115,7 @@
115
115
 
116
116
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
117
117
 
118
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
118
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
119
119
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
120
120
  <script src="/builders/palette.js" defer></script>
121
121
  <!-- the shared markdown reader (window.OTBMdReader) — primer.js renders through
@@ -187,7 +187,7 @@
187
187
 
188
188
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
189
189
  chrome exists before the page script runs. -->
190
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
190
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
191
191
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
192
192
  <script src="/builders/palette.js" defer></script>
193
193
  <!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
@@ -105,7 +105,7 @@
105
105
  ${disciplines}
106
106
  </div>
107
107
  <div class="profile__credits">
108
- <span class="profile__credits-num" title="${escapeHtml(curLabel(true))} — total payout from shipped work.">${fmtNum(builder.total_credits)}</span>
108
+ <span class="profile__credits-num" title="${escapeHtml(curLabel(true))} — everything earned: shipped work, approved pages and ideas.">${fmtNum(builder.total_credits)}</span>
109
109
  <span class="profile__credits-label">${escapeHtml(curLabel())}</span>
110
110
  <span class="profile__karma" title="Karma — peer recognition and approvals, a separate counter from ${escapeHtml(curLabel())}.">
111
111
  <span class="profile__karma-num">${fmtNum(builder.karma)}</span>
@@ -58,7 +58,7 @@
58
58
 
59
59
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
60
60
 
61
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
61
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
62
62
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
63
63
  <script src="/builders/palette.js" defer></script>
64
64
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -77,7 +77,7 @@
77
77
 
78
78
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
79
79
  chrome exists before the page script runs. -->
80
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
80
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
81
81
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
82
82
  <script src="/builders/palette.js" defer></script>
83
83
  <!-- shared frontend toolkit (window.OTB) — loaded before ranks.js, which pulls from it. -->
@@ -41,7 +41,7 @@
41
41
 
42
42
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
43
43
 
44
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
44
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
45
45
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
46
46
  <script src="/builders/palette.js" defer></script>
47
47
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
@@ -50,7 +50,7 @@
50
50
 
51
51
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
52
  chrome exists before the page script runs. -->
53
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
53
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
54
54
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
55
  <script src="/builders/palette.js" defer></script>
56
56
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -99,7 +99,7 @@
99
99
 
100
100
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
101
101
  chrome exists before the page script runs. -->
102
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
102
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
103
103
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
104
104
  <script src="/builders/palette.js" defer></script>
105
105
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
@@ -400,7 +400,7 @@
400
400
 
401
401
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
402
402
  chrome exists before the page scripts run. -->
403
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
403
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
404
404
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
405
405
  <script src="/builders/palette.js" defer></script>
406
406
  <!-- shared frontend toolkit (window.OTB) — loaded before consuming IIFEs. -->
@@ -442,7 +442,11 @@
442
442
 
443
443
  el.innerHTML =
444
444
  '<div class="app-sidebar__brand"><a class="brand-mark" href="/" id="brand-mark">' +
445
- '<span class="brand-mark__dot" aria-hidden="true"></span>' +
445
+ // A project's logo (ADR 0358, task 1004421) takes the dot's place; the
446
+ // platform validated it as an https image address before it was pushed.
447
+ (brand.theme && brand.theme.logo
448
+ ? '<img class="brand-mark__logo" src="' + esc(brand.theme.logo) + '" alt="" width="24" height="24" decoding="async">'
449
+ : '<span class="brand-mark__dot" aria-hidden="true"></span>') +
446
450
  '<span class="brand-mark__name" id="brand-name"></span></a></div>' +
447
451
  '<nav class="app-sidebar__nav" aria-label="Primary">' + groups + '</nav>' +
448
452
  '<div class="app-sidebar__foot">' +
@@ -176,7 +176,7 @@
176
176
 
177
177
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
178
178
 
179
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
179
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
180
180
  <script src="/builders/palette.js" defer></script>
181
181
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
182
182
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -561,6 +561,14 @@ main:focus { outline: none; }
561
561
  background: none;
562
562
  flex-shrink: 0;
563
563
  }
564
+ /* A project's logo in the dot's place (ADR 0358): the image is the founder's, so
565
+ it gets a soft square, never a recolour. */
566
+ .brand-mark__logo {
567
+ width: 24px; height: 24px;
568
+ border-radius: 6px;
569
+ object-fit: contain;
570
+ flex-shrink: 0;
571
+ }
564
572
  .brand-mark__name {
565
573
  display: none;
566
574
  white-space: nowrap;
@@ -57,7 +57,7 @@
57
57
 
58
58
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
59
59
  time so the chrome exists before the deferred page scripts execute. -->
60
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
60
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
61
61
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
62
62
  <script src="/builders/palette.js" defer></script>
63
63
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -138,7 +138,7 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
141
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
142
142
  <script src="/builders/palette.js" defer></script>
143
143
 
144
144
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -140,7 +140,7 @@
140
140
 
141
141
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
142
142
 
143
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
143
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
146
146
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
165
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-09-30-navback2"></script>
174
+ <script src="/builders/shell.js?v=2026-10-01-look"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/ideator-stats.js — THE IDEATOR'S NUMBERS for the profile
4
+ // (task 1004433 / WA6.RP03, goal 1000095, criterion wa6-roles-read-true-in-public;
5
+ // the owner-approved mock is docs/design/mocks/profile-roles/, task 1004431).
6
+ //
7
+ // The profile's header and "By the numbers" lead with each person's own craft.
8
+ // For an ideator that is six numbers, and every one of them is a COUNT or a SUM
9
+ // over the sky's own derivation, never a second one:
10
+ //
11
+ // filed thoughts filed (the person's trail, skill-friction rows left out)
12
+ // ignited thoughts that became paid work (state.hasWork, the sky's ignition)
13
+ // full Full Ideas (grade 'full')
14
+ // ratified Full Ideas the Board Room carried (government.boardPassForIdea)
15
+ // size credits earned from ideas across EVERY idea stream, plus the
16
+ // historical estimate, with the marker that says so
17
+ // alive the share of the work their thoughts caused that is still alive
18
+ //
19
+ // WHERE EACH COMES FROM. The trail is `readSkyMine` (./sky-mine.js), which walks
20
+ // the whole trail and runs the three attachers in the trail route's order, so a
21
+ // thought's size, brightness and state here are the ones the sky draws. Nothing
22
+ // in this file derives a state.
23
+ //
24
+ // SIZE IS EVERY STREAM, NOT STREAM 1. A thought's drawn size is its stream-1
25
+ // payout (economy/idea-size.js: the work it caused). The PERSON's total is a
26
+ // different question, "what has thinking earned you", and the owner's mock
27
+ // answers it across every way ideas are paid: the task ship and its descent, the
28
+ // Full Idea pass, the board pass and the spark/developer split. That is the
29
+ // ledger's ideator column (economy/credits-by-craft.js, through the `reward`
30
+ // port), so the headline agrees with the earnings chart to the credit. The
31
+ // historical estimate (spec decision 17) rides on top, from the per-thought
32
+ // sizes the sky already priced: money the ledger was never there to pay, so it
33
+ // is reported apart as `size.estimated` and marked, never folded in silently.
34
+ //
35
+ // ALIVE IS WEIGHTED BY SIZE, like a single thought's brightness: the live
36
+ // credits over all credits, across every thought that ignited. A thought that
37
+ // could not be read (`attributable: false`) makes the whole share unknown
38
+ // (null) rather than quietly counting it as alive, and a person whose thoughts
39
+ // caused nothing yet has no share at all (null, `has_work: false`): zero over
40
+ // zero is not "nothing survived". The two nulls are told apart by `has_work`.
41
+ //
42
+ // PUBLIC BY THE OWNER'S DECISION (a) of 2026-09-30: the identity band, the
43
+ // tally and "By the numbers" stay public, the trail itself does not. So these
44
+ // are counts and sums only. No title, no id, no triage note leaves this file,
45
+ // and the read is the same for every viewer: the skill-friction gate is applied
46
+ // at the least-privileged real rank, because those rows are a routine's
47
+ // filings, not a person's thoughts, and a count must not change with who looks.
48
+
49
+ const api = require('../../src/module-api');
50
+ const { readSkyMine } = require('./sky-mine');
51
+
52
+ // The rank the counts are read at. xenos is the one rank the skill-friction gate
53
+ // hides those rows from (./skill-friction.js); reading at it makes the public
54
+ // count the count of the person's own thoughts.
55
+ const PUBLIC_VIEWER_RANK = 'xenos';
56
+
57
+ function int(v) {
58
+ const n = Number(v);
59
+ return Number.isFinite(n) ? Math.round(n) : 0;
60
+ }
61
+
62
+ // summarizeIdeatorTrail — the six numbers from a trail that already carries
63
+ // size and state. Pure. `ideatorCredits` is the ledger's ideator column (null
64
+ // when economy could not be asked), `ratifiedIds` the set of idea ids the board
65
+ // carried (null when the government port is absent).
66
+ function summarizeIdeatorTrail(ideas, { ideatorCredits = null, ratifiedIds = null, truncated = false, total = null } = {}) {
67
+ const list = Array.isArray(ideas) ? ideas : [];
68
+ let ignited = 0;
69
+ let full = 0;
70
+ let ratified = 0;
71
+ let estimated = 0;
72
+ let estimateKnown = true;
73
+ let credits = 0;
74
+ let live = 0;
75
+ let attributable = true;
76
+ for (const idea of list) {
77
+ if (!idea) continue;
78
+ const st = idea.state || {};
79
+ if (st.hasWork === true) ignited++;
80
+ if (idea.grade === 'full') {
81
+ full++;
82
+ if (ratifiedIds && ratifiedIds.has(String(idea.id))) ratified++;
83
+ }
84
+ const size = idea.size;
85
+ if (!size) { estimateKnown = false; continue; }
86
+ if (size.estimated == null) estimateKnown = false;
87
+ else estimated += int(size.estimated);
88
+ if (st.hasWork === true) {
89
+ const c = Number(size.credits) || 0;
90
+ credits += c;
91
+ if (st.attributable !== true || st.brightness == null) attributable = false;
92
+ else live += c * Number(st.brightness);
93
+ }
94
+ }
95
+ const paid = ideatorCredits == null ? null : int(ideatorCredits);
96
+ const est = estimateKnown ? estimated : null;
97
+ return {
98
+ // The trail's own count, not the page in hand: past the sky's cap the list
99
+ // is the newest thousand and `truncated` says the other numbers are partial.
100
+ filed: Number.isInteger(total) ? total : list.length,
101
+ ignited,
102
+ full_ideas: full,
103
+ ratified: ratifiedIds ? ratified : null,
104
+ size: {
105
+ total: paid == null ? null : paid + (est || 0),
106
+ paid,
107
+ estimated: est,
108
+ historical: est == null ? null : est > 0,
109
+ },
110
+ alive: {
111
+ share: credits > 0 && attributable ? live / credits : null,
112
+ has_work: credits > 0,
113
+ attributable: credits > 0 ? attributable : null,
114
+ },
115
+ truncated: truncated === true,
116
+ };
117
+ }
118
+
119
+ // readIdeatorStats — the async half: the trail, the ledger column, the board.
120
+ // `deps` lets a test hand in each port; production resolves them optionally, so
121
+ // an instance without economy or the board reads `null` for those numbers
122
+ // (could not ask) rather than 0 (asked, and nothing was there).
123
+ async function readIdeatorStats(builderId, deps = {}) {
124
+ const read = deps.readSkyMine || readSkyMine;
125
+ const reward = 'reward' in deps ? deps.reward : (api.resolveOptional && api.resolveOptional('reward'));
126
+ const government = 'government' in deps ? deps.government : (api.resolveOptional && api.resolveOptional('government'));
127
+
128
+ const sky = await read({ builderId, viewerRank: PUBLIC_VIEWER_RANK });
129
+ const ideas = (sky && sky.ideas) || [];
130
+
131
+ let ideatorCredits = null;
132
+ if (reward && typeof reward.creditsByCraft === 'function') {
133
+ const byCraft = await reward.creditsByCraft([builderId]);
134
+ const row = byCraft.get(String(builderId));
135
+ ideatorCredits = row ? row.ideator : 0;
136
+ }
137
+
138
+ let ratifiedIds = null;
139
+ if (government && typeof government.boardPassForIdea === 'function') {
140
+ ratifiedIds = new Set();
141
+ const fulls = ideas.filter((i) => i && i.grade === 'full');
142
+ const passes = await Promise.all(fulls.map((i) => government.boardPassForIdea(i.id)));
143
+ fulls.forEach((i, k) => { if (passes[k]) ratifiedIds.add(String(i.id)); });
144
+ }
145
+
146
+ return summarizeIdeatorTrail(ideas, { ideatorCredits, ratifiedIds, truncated: sky && sky.truncated, total: sky && sky.total });
147
+ }
148
+
149
+ module.exports = { readIdeatorStats, summarizeIdeatorTrail, PUBLIC_VIEWER_RANK };
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "provides": [
23
23
  "ideas.capture",
24
- "ideas.descent"
24
+ "ideas.descent",
25
+ "ideas.ideatorStats"
25
26
  ],
26
27
  "consumes": [
27
28
  "reward",
@@ -36,6 +36,7 @@ const express = require('express');
36
36
  const api = require('../../../src/module-api');
37
37
  const inbox = require('../inbox');
38
38
  const bornOf = require('../born-of');
39
+ const ideatorStats = require('../ideator-stats');
39
40
  // The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
40
41
  // own file because inbox.js is AT the size ratchet and because a task->task
41
42
  // walk is a different relation from the idea->task trail.
@@ -108,6 +109,12 @@ function registerSeams() {
108
109
  descendantsOfTask: descent.descendantsOfTask,
109
110
  });
110
111
  }
112
+ // task 1004433 (WA6.RP03): the ideator's numbers for the profile, counts and
113
+ // sums only, so core's profile route can show them without reading an ideas_
114
+ // table or deriving a state (../ideator-stats.js).
115
+ if (!api.hasProvider('ideas.ideatorStats')) {
116
+ api.registerProvider('ideas.ideatorStats', ideatorStats.readIdeatorStats);
117
+ }
111
118
  }
112
119
 
113
120
  // The author-facing shape of a ratification window this request just opened —
@@ -36,7 +36,10 @@
36
36
  const api = require('../../src/module-api');
37
37
  const { pool } = api;
38
38
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
39
- const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
39
+ // What THIS person was paid for each row, not the task's whole payout (task
40
+ // 1004433): on a page tweak the artist and the applier are two people's pay, and
41
+ // under cost-plus-only the per-task estimate books nothing at all.
42
+ const { creditsEarnedBySql } = require('./db-tasks.js');
40
43
 
41
44
  // The craft vocabulary this read accepts. Mirrors tasks_discipline_check
42
45
  // (migrations 086 + 176). Anything else is treated as "no filter" rather than
@@ -97,7 +100,7 @@ async function activityTasksByBuilder(builderId, { role = null, limit = DEFAULT_
97
100
  `SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt,
98
101
  t.version_id, t.goal_id, t.kind, t.status, t.shipped_at,
99
102
  COALESCE(t.discipline, 'unclassified') AS discipline,
100
- t.credits_reward, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
103
+ t.credits_reward, ${creditsEarnedBySql('$1')} AS credits_awarded
101
104
  FROM tasks t
102
105
  WHERE ${where}
103
106
  ORDER BY t.shipped_at DESC NULLS LAST, t.id DESC
@@ -13,7 +13,7 @@
13
13
  const api = require('../../src/module-api');
14
14
  const { pool, withTx } = api;
15
15
  const { SMOKE_NOT_LIKE, gradePassCount, gradeQualityWhere, median } = require('./db-shared.js');
16
- const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
16
+ const { CREDITS_AWARDED_SUBQUERY, creditsEarnedBySql } = require('./db-tasks.js');
17
17
  const { helperJoinSql } = require('./page-tweak-artist.js');
18
18
 
19
19
  // -------------------------------------------------------------------------
@@ -137,8 +137,10 @@ async function estimationDriftSummary() {
137
137
 
138
138
  async function recentShippedTasksByBuilder(builderId, limit = 10) {
139
139
  const { rows } = await pool.query(
140
+ // task 1004433: the profile's recent works show what THIS builder was paid
141
+ // (creditsEarnedBySql), not the task's whole payout across everyone.
140
142
  `SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt, t.version_id, t.status, t.shipped_at, t.credits_reward,
141
- ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
143
+ ${creditsEarnedBySql('$1')} AS credits_awarded
142
144
  FROM tasks t
143
145
  WHERE t.shipped_by = $1
144
146
  AND t.status = 'shipped'
@@ -676,14 +676,61 @@ async function updateTaskModuleKey(id, moduleKey) { return updateTaskColumn(id,
676
676
  // CASE still short-circuits to 0 for unsettled tasks; note a 'confirmed' task
677
677
  // now shows 0 until it LANDS (the credit no longer exists at confirm) — the UI
678
678
  // renders the "Est." label until the 'task.shipped' row books at land.
679
+ //
680
+ // task 1004433 (WA6.RP03): the allowlist missed two of the per-task streams, so
681
+ // an approved page read "0 credits" and an idea's payout for the task it caused
682
+ // never counted. Both are booked against the task's own id and are payment FOR
683
+ // that task: `tweak.page_approved:<task>` (the artist lane, ADR 0341 D10, paid
684
+ // INSTEAD of the per-task estimate) and every `idea.credit.*` row carrying this
685
+ // task_id (stream 1 and its descent, ADR 0172 / 0312). Streams 2 and 3 carry no
686
+ // task_id and stay out, as they pay for the thought, not for any task. Matched
687
+ // as prefixes, never by equality: each key embeds the ids it was paid for.
688
+ const TASK_PAYOUT_REASON_SQL =
689
+ `(cl.reason IN ('task.confirmed', 'task.shipped', 'idea_promotion_bonus', 'ship.net_negative')
690
+ OR cl.reason LIKE 'tweak.page\\_approved:%'
691
+ OR cl.reason LIKE 'idea.credit.%')`;
679
692
  const CREDITS_AWARDED_SUBQUERY =
680
693
  `CASE WHEN t.status IN ('confirmed', 'shipped')
681
694
  THEN (SELECT COALESCE(SUM(cl.delta), 0)::int
682
695
  FROM credit_log cl
683
696
  WHERE cl.task_id = t.id
684
- AND cl.reason IN ('task.confirmed', 'task.shipped', 'idea_promotion_bonus', 'ship.net_negative'))
697
+ AND ${TASK_PAYOUT_REASON_SQL})
685
698
  ELSE 0 END`;
686
699
 
700
+ // creditsEarnedBySql — what ONE person earned for a task, for the lists that
701
+ // say "this is what <person> was paid" (the profile's recent works and its
702
+ // Activity tab). CREDITS_AWARDED_SUBQUERY above is the TASK's payout across
703
+ // everyone, which was the wrong number there twice over: on a page tweak the
704
+ // artist's page credit and the applier's session reward are two people's pay,
705
+ // and under cost-plus-only (ADR 0146) the per-task estimate books nothing, so a
706
+ // builder who was paid normally read 0 on every row.
707
+ //
708
+ // Two halves, the two attribution keys sumSessionEarnings names (task 1003634):
709
+ // • every per-task payout row this builder holds against the task;
710
+ // • the cost-plus SESSION reward (task_id NULL, keyed by session) for each of
711
+ // this builder's sessions that shipped the task, split evenly across the
712
+ // tasks that session shipped. The session pays for its whole token spend,
713
+ // not for one task, so an even share is the honest per-task figure.
714
+ // `builderRef` is a bound parameter reference such as '$1', never a value.
715
+ // session_records is read through idx_session_records_builder.
716
+ function creditsEarnedBySql(builderRef) {
717
+ if (!/^\$\d+$/.test(builderRef)) throw new Error('creditsEarnedBySql: builderRef must be a bind parameter like $1');
718
+ return `CASE WHEN t.status IN ('confirmed', 'shipped')
719
+ THEN (COALESCE((SELECT SUM(cl.delta)
720
+ FROM credit_log cl
721
+ WHERE cl.task_id = t.id
722
+ AND cl.builder_id = ${builderRef}
723
+ AND ${TASK_PAYOUT_REASON_SQL}), 0)
724
+ + COALESCE((SELECT ROUND(SUM(cl.delta::numeric / GREATEST(cardinality(sr.task_ids), 1)))
725
+ FROM session_records sr
726
+ JOIN credit_log cl ON cl.session_id = sr.session_id
727
+ AND cl.reason = 'session.token_reward'
728
+ AND cl.builder_id = ${builderRef}
729
+ WHERE sr.builder_id = ${builderRef}
730
+ AND t.id = ANY(sr.task_ids)), 0))::int
731
+ ELSE 0 END`;
732
+ }
733
+
687
734
  async function getTask(id) {
688
735
  const { rows } = await pool.query(
689
736
  `SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
@@ -1343,6 +1390,7 @@ async function listOpenArtistReviews({ source, refPrefix, resolvedStatuses = [],
1343
1390
 
1344
1391
  module.exports = {
1345
1392
  CREDITS_AWARDED_SUBQUERY,
1393
+ creditsEarnedBySql,
1346
1394
  REWARD_SUGGESTION_CAP,
1347
1395
  createCriterion,
1348
1396
  createTask,
@@ -123,15 +123,16 @@ const {
123
123
  // the next setter costs nothing.
124
124
  //
125
125
  // TASK_FACADE_OMIT is what keeps this surface-PRESERVING rather than quietly
126
- // wider. Two of db-tasks.js's 33 exports were deliberately never re-exported
126
+ // wider. Three of db-tasks.js's 34 exports were deliberately never re-exported
127
127
  // here, and a bare spread would publish them — which ADR 0091 §3 treats as a
128
128
  // real surface change, not a tidy-up. Naming them means dropping one has to be
129
129
  // somebody's decision. tests/lifecycle_facade_surface.mjs pins the resulting key
130
130
  // set, so this can never silently widen or narrow.
131
131
  const dbTasks = require('./db-tasks.js');
132
132
  const TASK_FACADE_OMIT = new Set([
133
- // Internal to db-tasks.js's own queries — a SQL fragment, not a callable.
133
+ // Internal to the lifecycle's own queries — SQL fragments, not callables.
134
134
  'CREDITS_AWARDED_SUBQUERY',
135
+ 'creditsEarnedBySql',
135
136
  // The version-gate predicate; routes reach it through db-versions, not here.
136
137
  'versionAcceptsNewWork',
137
138
  ]);
@@ -45,11 +45,27 @@ const artistRule = require('./page-tweak-artist');
45
45
  // autosave runs this on every debounced save, and a page's rounds are a handful
46
46
  // while the instance's grow without bound. The prefix is matched with left(),
47
47
  // not LIKE, the page claim's own rule (an `_` in an id is never a wildcard).
48
- async function listPageTweakTasks({ pageId = null } = {}) {
48
+ //
49
+ // `artistId` narrows it to ONE artist's rounds (task 1004433 / WA6.RP03): the
50
+ // profile's artist numbers read this on a public page, and an artist's rounds
51
+ // are a handful while the instance's grow without bound. The rounds are found
52
+ // FROM that builder's own web claims (claims by builder_id), never by listing
53
+ // every round and filtering, and a round counts only while its LATEST web
54
+ // claim is still theirs: the D10 payee rule the unscoped read applies.
55
+ // `client` lets a caller run it inside its own transaction (tests/role_stats_db.mjs
56
+ // runs the artist branch against real Postgres, rolled back); default the pool.
57
+ async function listPageTweakTasks({ pageId = null, artistId = null, client = null } = {}) {
49
58
  const prefix = pageId ? `page-tweak/${pageId}/` : null;
50
- const { rows } = await api.pool.query(
59
+ const artist = artistId == null ? null : String(artistId);
60
+ const { rows } = await (client || api.pool).query(
51
61
  `WITH pt AS (
52
- SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
62
+ SELECT t.id FROM tasks t WHERE $2::bigint IS NULL AND t.source = 'page-tweak'
63
+ AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
64
+ UNION
65
+ SELECT t.id FROM claims c JOIN tasks t ON t.id = c.task_id
66
+ WHERE $2::bigint IS NOT NULL AND c.builder_id = $2::bigint
67
+ AND ${artistRule.webClaimPredicate('c')}
68
+ AND t.source = 'page-tweak'
53
69
  AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
54
70
  ), web AS (
55
71
  SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
@@ -63,7 +79,8 @@ async function listPageTweakTasks({ pageId = null } = {}) {
63
79
  ac.builder_id AS holder_id, hb.github_login AS holder_login,
64
80
  (ac.worktree_name IS NOT NULL) AS holder_worktree,
65
81
  wc.builder_id AS artist_id, ab.github_login AS artist_login
66
- FROM tasks t
82
+ FROM pt
83
+ JOIN tasks t ON t.id = pt.id
67
84
  LEFT JOIN task_grades g ON g.task_id = t.id
68
85
  LEFT JOIN claims ac ON ac.task_id = t.id AND ac.released_at IS NULL
69
86
  LEFT JOIN builders hb ON hb.id = ac.builder_id
@@ -71,8 +88,9 @@ async function listPageTweakTasks({ pageId = null } = {}) {
71
88
  LEFT JOIN builders ab ON ab.id = wc.builder_id
72
89
  WHERE t.source = 'page-tweak'
73
90
  AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
91
+ AND ($2::bigint IS NULL OR wc.builder_id = $2::bigint)
74
92
  ORDER BY t.id ASC`,
75
- [prefix]
93
+ [prefix, artist]
76
94
  );
77
95
  return rows;
78
96
  }