@bongos/core 1.19.442 → 1.19.444

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 (168) hide show
  1. package/.bongos-core.json +470 -455
  2. package/config/branding.neutral.json +1 -1
  3. package/docs/adr/0054-session-token-reward-cost-plus.md +14 -0
  4. package/docs/adr/0218-the-apex-root-web-surface.md +55 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/architecture.md +2 -1
  7. package/docs/copy-inventory.md +360 -360
  8. package/docs/copy-registry.json +456 -456
  9. package/docs/design/mocks/hub/mine.html +1 -1
  10. package/docs/design/mocks/hub/picker.html +1 -1
  11. package/docs/design/mocks/hub/planets.html +1 -1
  12. package/docs/design/mocks/hub/v1.html +3 -3
  13. package/docs/design/mocks/hub/v2.html +2 -2
  14. package/docs/design/mocks/hub/v3.html +5 -5
  15. package/docs/design/mocks/hub/wizard.html +1 -1
  16. package/docs/design/mocks/landing/v1.html +1 -1
  17. package/docs/design/mocks/landing/v2.html +1 -1
  18. package/docs/design/mocks/landing/v3.html +1 -1
  19. package/docs/design/mocks/landing-page-mock.html +3 -3
  20. package/docs/design/mocks/projects-hub-mock.html +1 -1
  21. package/docs/design/mocks/projects-hub-variants.html +1 -1
  22. package/docs/design/mocks/projects-page-mock.html +1 -1
  23. package/docs/file-map.md +3 -3
  24. package/docs/module-api-changelog.md +4 -0
  25. package/docs/modules-contract.md +9 -2
  26. package/docs/recipes/ui-look-before-you-ship.md +4 -4
  27. package/modules/copy-desk/tests/copy_flags.mjs +4 -4
  28. package/modules/public-landing/CLAUDE.md +15 -0
  29. package/modules/public-landing/module.json +15 -0
  30. package/modules/ui-design/kit/lib.js +2 -2
  31. package/modules/ui-design/kit/render.js +1 -1
  32. package/modules/ui-design/kit/serve.js +3 -3
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/scripts/gds/copy-inventory.js +3 -3
  36. package/scripts/gds/fitness-checks-identity.js +18 -1
  37. package/scripts/gds/publish-manifest.js +1 -1
  38. package/scripts/gds/session-digest-build.js +49 -17
  39. package/scripts/gds/session-digest.js +11 -1
  40. package/scripts/gds/ship-session-upload.js +102 -12
  41. package/scripts/gds/ship.js +7 -1
  42. package/src/bongos/module-scope-map.js +9 -7
  43. package/src/bongos/serve-internal.js +13 -1
  44. package/src/module-api.js +1 -1
  45. package/src/module-loader/loader.js +34 -17
  46. package/src/module-loader/manifest-schema.js +16 -8
  47. package/src/platform-server.js +50 -18
  48. package/tests/copy_inventory.mjs +0 -0
  49. package/tests/cosmos_token_contrast.mjs +6 -6
  50. package/tests/credit_grant.mjs +236 -0
  51. package/tests/fitness.mjs +20 -0
  52. package/tests/landing_legal_pages.mjs +2 -1
  53. package/tests/landing_page.mjs +7 -5
  54. package/tests/module-scope-map.mjs +2 -2
  55. package/tests/module_loader.mjs +4 -1
  56. package/tests/onboard.mjs +5 -5
  57. package/tests/projects_hub.mjs +22 -20
  58. package/tests/projects_hub_pre_uat.mjs +5 -3
  59. package/tests/public_landing_projects.mjs +2 -2
  60. package/tests/terms_acceptance.mjs +1 -1
  61. package/tests/ui_design_kit.mjs +3 -3
  62. package/tests/world_tokens.mjs +3 -2
  63. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_00.webp +0 -0
  64. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_01.webp +0 -0
  65. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_02.webp +0 -0
  66. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_03.webp +0 -0
  67. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_04.webp +0 -0
  68. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_05.webp +0 -0
  69. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_06.webp +0 -0
  70. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_07.webp +0 -0
  71. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_08.webp +0 -0
  72. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_09.webp +0 -0
  73. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_10.webp +0 -0
  74. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_11.webp +0 -0
  75. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_12.webp +0 -0
  76. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_13.webp +0 -0
  77. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_14.webp +0 -0
  78. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_15.webp +0 -0
  79. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_16.webp +0 -0
  80. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_17.webp +0 -0
  81. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_18.webp +0 -0
  82. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_19.webp +0 -0
  83. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_20.webp +0 -0
  84. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_21.webp +0 -0
  85. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_22.webp +0 -0
  86. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_23.webp +0 -0
  87. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_24.webp +0 -0
  88. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_25.webp +0 -0
  89. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_26.webp +0 -0
  90. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_27.webp +0 -0
  91. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_28.webp +0 -0
  92. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_29.webp +0 -0
  93. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_30.webp +0 -0
  94. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_31.webp +0 -0
  95. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_32.webp +0 -0
  96. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_33.webp +0 -0
  97. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_34.webp +0 -0
  98. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_35.webp +0 -0
  99. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_36.webp +0 -0
  100. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_37.webp +0 -0
  101. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_38.webp +0 -0
  102. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_39.webp +0 -0
  103. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_40.webp +0 -0
  104. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_41.webp +0 -0
  105. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_42.webp +0 -0
  106. /package/{public-landing → modules/public-landing/public}/assets/anim/idle/frame_43.webp +0 -0
  107. /package/{public-landing → modules/public-landing/public}/assets/cosmos.css +0 -0
  108. /package/{public-landing → modules/public-landing/public}/assets/verro-medallion.png +0 -0
  109. /package/{public-landing → modules/public-landing/public}/assets/world/og-card.jpg +0 -0
  110. /package/{public-landing → modules/public-landing/public}/assets/world/r2-bongos-chrome-a-cut.webp +0 -0
  111. /package/{public-landing → modules/public-landing/public}/assets/world/r2-bongos-chrome-a-cut.webp.json +0 -0
  112. /package/{public-landing → modules/public-landing/public}/assets/world/r2-bongos-chrome-b.jpg +0 -0
  113. /package/{public-landing → modules/public-landing/public}/assets/world/r2-bongos-chrome-b.jpg.json +0 -0
  114. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  115. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blob-one-cut.webp.json +0 -0
  116. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blob-one.jpg +0 -0
  117. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blob-one.jpg.json +0 -0
  118. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blobs-set-cut.webp +0 -0
  119. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blobs-set-cut.webp.json +0 -0
  120. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blobs-set.jpg +0 -0
  121. /package/{public-landing → modules/public-landing/public}/assets/world/r2-chrome-blobs-set.jpg.json +0 -0
  122. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-bead-cut.webp +0 -0
  123. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-bead-cut.webp.json +0 -0
  124. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-blob-cut.webp +0 -0
  125. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-blob-cut.webp.json +0 -0
  126. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-pair-cut.webp +0 -0
  127. /package/{public-landing → modules/public-landing/public}/assets/world/r3-frost-pair-cut.webp.json +0 -0
  128. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-bead-cut.webp +0 -0
  129. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-bead-cut.webp.json +0 -0
  130. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-blob-cut.webp +0 -0
  131. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-blob-cut.webp.json +0 -0
  132. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-pair-cut.webp +0 -0
  133. /package/{public-landing → modules/public-landing/public}/assets/world/r3-gold-pair-cut.webp.json +0 -0
  134. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-bead-cut.webp +0 -0
  135. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-bead-cut.webp.json +0 -0
  136. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-blob-cut.webp +0 -0
  137. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-blob-cut.webp.json +0 -0
  138. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-pair-cut.webp +0 -0
  139. /package/{public-landing → modules/public-landing/public}/assets/world/r3-obsidian-pair-cut.webp.json +0 -0
  140. /package/{public-landing → modules/public-landing/public}/assets/world/r3-space-nebula.jpg +0 -0
  141. /package/{public-landing → modules/public-landing/public}/assets/world/r3-space-nebula.jpg.json +0 -0
  142. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-chrome.jpg +0 -0
  143. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-chrome.jpg.json +0 -0
  144. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-deepfield.jpg +0 -0
  145. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-deepfield.jpg.json +0 -0
  146. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-dust.jpg +0 -0
  147. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-dust.jpg.json +0 -0
  148. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-nebula.jpg +0 -0
  149. /package/{public-landing → modules/public-landing/public}/assets/world/r4-card-nebula.jpg.json +0 -0
  150. /package/{public-landing → modules/public-landing/public}/assets/world.css +0 -0
  151. /package/{public-landing → modules/public-landing/public}/buddha.png +0 -0
  152. /package/{public-landing → modules/public-landing/public}/contact.html +0 -0
  153. /package/{public-landing → modules/public-landing/public}/index.html +0 -0
  154. /package/{public-landing → modules/public-landing/public}/index.probes.json +0 -0
  155. /package/{public-landing → modules/public-landing/public}/index.states.json +0 -0
  156. /package/{public-landing → modules/public-landing/public}/privacy.html +0 -0
  157. /package/{public-landing → modules/public-landing/public}/projects-empty.states.json +0 -0
  158. /package/{public-landing → modules/public-landing/public}/projects-floor.states.json +0 -0
  159. /package/{public-landing → modules/public-landing/public}/projects.html +0 -0
  160. /package/{public-landing → modules/public-landing/public}/projects.probes.json +0 -0
  161. /package/{public-landing → modules/public-landing/public}/projects.states.json +0 -0
  162. /package/{public-landing → modules/public-landing/public}/terms.html +0 -0
  163. /package/{public-landing → modules/public-landing/public}/world/assets/clouds/cloud-island-large.png +0 -0
  164. /package/{public-landing → modules/public-landing/public}/world/assets/clouds/cloud-puff.png +0 -0
  165. /package/{public-landing → modules/public-landing/public}/world/assets/creatures/creature-whale-cargo.png +0 -0
  166. /package/{public-landing → modules/public-landing/public}/world/assets/creatures/creature-whale-glide.png +0 -0
  167. /package/{public-landing → modules/public-landing/public}/world/assets/plates/plate-clouds-near.png +0 -0
  168. /package/{public-landing → modules/public-landing/public}/world/assets/plates/scene-establishing.png +0 -0
@@ -87,6 +87,16 @@ const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
87
87
  function buildSessionDigest(entries, opts = {}) {
88
88
  const extra = opts.extraSecrets || [];
89
89
  const MAX_TURNS = opts.maxTurns || 4000;
90
+ // usageOnly — build the token metrics but no `detail`. For the callers that
91
+ // want ONLY a transcript's model_usage (the subagents-subtree fold, task
92
+ // 1003435), where the per-turn rows would be discarded anyway and the snippet
93
+ // scrubbing is the expensive part. It exists so that fold can reuse THIS
94
+ // function — the one place the token semantics live — instead of a second copy
95
+ // that could drift, without paying for output nobody reads. The waste is worth
96
+ // avoiding on both upload paths, and on the SessionEnd one it is load-bearing:
97
+ // that hook runs under a 6-second watchdog, so a slow pass there doesn't just
98
+ // cost time, it drops the upload.
99
+ const usageOnly = opts.usageOnly === true;
90
100
  const seenMessageIds = new Set();
91
101
  const m = {
92
102
  assistant_turns: 0,
@@ -165,15 +175,17 @@ function buildSessionDigest(entries, opts = {}) {
165
175
  }
166
176
  }
167
177
  }
168
- const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
169
- // Marks a line that shares its API call with an earlier one, so a reader
170
- // can tell a genuinely-zero-usage turn from a de-duplicated repeat.
171
- if (dupUsage) row.dup = 1;
172
- if (tools.length) row.tools = tools;
173
- if (textLen) row.text_len = textLen;
174
- const snip = snippetOf(firstText, extra);
175
- if (snip) row.snippet = snip;
176
- detail.push(row);
178
+ if (!usageOnly) {
179
+ const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
180
+ // Marks a line that shares its API call with an earlier one, so a reader
181
+ // can tell a genuinely-zero-usage turn from a de-duplicated repeat.
182
+ if (dupUsage) row.dup = 1;
183
+ if (tools.length) row.tools = tools;
184
+ if (textLen) row.text_len = textLen;
185
+ const snip = snippetOf(firstText, extra);
186
+ if (snip) row.snippet = snip;
187
+ detail.push(row);
188
+ }
177
189
  i++;
178
190
  } else if (e.type === 'user' && msg) {
179
191
  m.user_turns++;
@@ -206,13 +218,15 @@ function buildSessionDigest(entries, opts = {}) {
206
218
  }
207
219
  }
208
220
  }
209
- const row = { i, role: 'user', ts };
210
- if (results) row.tool_results = results;
211
- if (errors) row.tool_errors = errors;
212
- if (harnessRetries) row.harness_retries = harnessRetries;
213
- const snip = snippetOf(firstResult, extra);
214
- if (snip) row.snippet = snip;
215
- detail.push(row);
221
+ if (!usageOnly) {
222
+ const row = { i, role: 'user', ts };
223
+ if (results) row.tool_results = results;
224
+ if (errors) row.tool_errors = errors;
225
+ if (harnessRetries) row.harness_retries = harnessRetries;
226
+ const snip = snippetOf(firstResult, extra);
227
+ if (snip) row.snippet = snip;
228
+ detail.push(row);
229
+ }
216
230
  i++;
217
231
  }
218
232
  }
@@ -258,4 +272,22 @@ function buildSessionDigest(entries, opts = {}) {
258
272
  detail: boundedDetail,
259
273
  };
260
274
  }
261
- module.exports = { snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
275
+ // mergeModelUsage — fold one model_usage map into another, in place, so a
276
+ // session's tokens can be summed across the several transcripts one session
277
+ // actually writes (the interactive thread + its subagent subtree — task
278
+ // 1003435). PURE apart from mutating `target`, which the caller owns. The four
279
+ // buckets are the session_records model_usage shape, NOT the transcript
280
+ // *_input_tokens shape; keep them in step with buildSessionDigest above.
281
+ function mergeModelUsage(target, addition) {
282
+ for (const [model, u] of Object.entries(addition || {})) {
283
+ const bucket = target[model] || (target[model] =
284
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
285
+ bucket.input += Number(u && u.input) || 0;
286
+ bucket.output += Number(u && u.output) || 0;
287
+ bucket.cache_read += Number(u && u.cache_read) || 0;
288
+ bucket.cache_creation += Number(u && u.cache_creation) || 0;
289
+ }
290
+ return target;
291
+ }
292
+
293
+ module.exports = { snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
@@ -34,7 +34,7 @@ const fs = require('fs');
34
34
  const os = require('os');
35
35
  const path = require('path');
36
36
  const { cliClient, loadSession, arg } = require('./cli-lib');
37
- const { buildSessionDigest, collectEnvSecrets } = require('./ship');
37
+ const { buildSessionDigest, collectEnvSecrets, foldSubagentUsage } = require('./ship');
38
38
 
39
39
 
40
40
  // Claude Code encodes a project transcript dir by replacing every non-alnum
@@ -144,6 +144,10 @@ async function main() {
144
144
  if (session.token) extraSecrets.push(session.token);
145
145
 
146
146
  const digest = buildSessionDigest(parseTranscript(transcriptPath), { extraSecrets });
147
+ // task 1003435: fold this session's subagents subtree into model_usage, the
148
+ // same way the on-ship upload does — the whole session's tokens, not just the
149
+ // interactive thread's.
150
+ foldSubagentUsage(digest, transcriptPath, sessionId);
147
151
 
148
152
  // task_ids:[] — the server upsert UNIONs task_ids, so the on-ship task
149
153
  // attribution is preserved; this end-of-session upload's job is to refresh
@@ -157,6 +161,12 @@ async function main() {
157
161
  ended_at: digest.endedAt,
158
162
  detail: digest.detail,
159
163
  metrics: digest.metrics,
164
+ // model_usage is its OWN column, and the upsert overwrites it from the
165
+ // body — so omitting it here (as this hook did until task 1003435) wiped
166
+ // the map the on-ship upload had just stored, leaving the reward with
167
+ // nothing to price. It is the LAST upload of the session, so it must carry
168
+ // the fullest picture, not the emptiest.
169
+ model_usage: digest.metrics.model_usage || {},
160
170
  };
161
171
  const api = await cliClient({ allowUnauthenticated: true });
162
172
  const r = await api.sessions.postSessions({ body: payload });
@@ -43,9 +43,16 @@ const {
43
43
  // shared with session-digest.js. Re-exported below so this file's public surface
44
44
  // and tests/session_records.mjs are unchanged.
45
45
  const {
46
- snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
46
+ snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
47
47
  } = require('./session-digest-build');
48
48
 
49
+ // The subagent subtree walker + the grader-transcript signature live in
50
+ // session-cost.js, which already prices that subtree for the SPEND ledger. The
51
+ // reward reads the same tokens, so it must use the SAME two definitions or the
52
+ // two ledgers describe different sessions (task 1003435). session-cost.js guards
53
+ // its own entry point behind require.main, so importing it is side-effect free.
54
+ const { listJsonlRecursive, looksLikeGraderTranscript } = require('./session-cost.js');
55
+
49
56
 
50
57
  // classifyUploadSkip — PURE decision for whether the session upload must skip,
51
58
  // and the clear, actionable reason why. Separated from I/O so it is unit-tested
@@ -109,6 +116,94 @@ function recordUploadSkip(taskId, { code, reason }, opts = {}) {
109
116
  }
110
117
  }
111
118
 
119
+ // parseJsonl — one transcript file's text into parsed entries, skipping any
120
+ // malformed line (best-effort, exactly like the rest of this path).
121
+ function parseJsonl(raw) {
122
+ const entries = [];
123
+ for (const line of String(raw || '').split('\n')) {
124
+ const t = line.trim();
125
+ if (!t) continue;
126
+ try {
127
+ entries.push(JSON.parse(t));
128
+ } catch (_) {
129
+ /* skip a malformed line — best-effort */
130
+ }
131
+ }
132
+ return entries;
133
+ }
134
+
135
+ // subagentModelUsage — the per-model TOKEN counts this session's ad-hoc
136
+ // subagents burned, so the cost-plus reward (ADR 0054) can see them.
137
+ //
138
+ // WHY (task 1003435): Agent/Task-tool spawns persist in a sibling subtree,
139
+ // <project-dir>/<session-id>/subagents/*.jsonl — never in the main transcript.
140
+ // session-cost.js already prices that subtree into a `session:subagent` cost_log
141
+ // row, so the SPEND ledger sees it; the digest read one file, so the REWARD did
142
+ // not. That paid a builder who follows CLAUDE.md's "delegate freely" less than
143
+ // one who did identical work in the main thread, and made the two ledgers
144
+ // disagree about the same session.
145
+ //
146
+ // Grader transcripts are excluded BY SIGNATURE, not by location: a grader run
147
+ // can nest inside a subagents/ dir, and its cost is already captured via the
148
+ // worker envelope, so pricing it here would double-count (the ADR 0028
149
+ // invariant). Each file goes through buildSessionDigest so the token semantics —
150
+ // dedup by message.id, skip `<synthetic>`, fold unlabeled turns into a priceable
151
+ // bucket — are the ONE implementation, never a second one that can drift.
152
+ function subagentModelUsage(sessionDir) {
153
+ const modelUsage = {};
154
+ let files = 0;
155
+ let tokens = 0;
156
+ let graderSkipped = 0;
157
+ for (const f of listJsonlRecursive(sessionDir)) {
158
+ if (looksLikeGraderTranscript(f)) { graderSkipped++; continue; }
159
+ let digest;
160
+ try {
161
+ digest = buildSessionDigest(parseJsonl(fs.readFileSync(f, 'utf8')), { usageOnly: true });
162
+ } catch (_) {
163
+ continue; // an unreadable subagent file must never fail a ship
164
+ }
165
+ if (!(digest.totalTokens > 0)) continue;
166
+ files++;
167
+ tokens += digest.totalTokens;
168
+ mergeModelUsage(modelUsage, digest.metrics.model_usage);
169
+ }
170
+ return { modelUsage, files, tokens, graderSkipped };
171
+ }
172
+
173
+ // foldSubagentUsage — add the session's subagent tokens to `digest`'s
174
+ // model_usage, IN PLACE, and record what was folded.
175
+ //
176
+ // model_usage ONLY, deliberately (task 1003435). model_usage is the reward's
177
+ // input, so it must describe the whole session. `detail`, `metrics.tokens`,
178
+ // `assistant_turns` and `total_tokens` stay the INTERACTIVE thread's record:
179
+ // they feed the BFG corpus + the inefficiency evaluator, which reason about one
180
+ // agent's turn-by-turn behaviour, and splicing N subagent transcripts into that
181
+ // per-turn story would distort the very signal it exists to measure (and bloat
182
+ // the payload). The three subagent_* counters are how a reader tells the two
183
+ // apart instead of inferring a discrepancy from a mismatch.
184
+ function foldSubagentUsage(digest, transcriptPath, sessionId) {
185
+ if (!digest || !digest.metrics || !transcriptPath || !sessionId) return digest;
186
+ // Path-traversal guard. The session id is interpolated into a filesystem path
187
+ // below, and what comes out of that path is priced into a payout — so a value
188
+ // carrying `../` could aim the walk at an arbitrary directory and fold
189
+ // fabricated token counts into the reward's input. Guarded HERE rather than at
190
+ // each call site: uploadSessionOnShip already screens the id before resolving
191
+ // the transcript, but the SessionEnd hook takes it straight off stdin, and any
192
+ // future caller would inherit the same hole. Same shape as the check in
193
+ // uploadSessionOnShip — a plain UUID-ish token, nothing else.
194
+ if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId))) return digest;
195
+ const sessionDir = path.join(path.dirname(transcriptPath), sessionId);
196
+ const sub = subagentModelUsage(sessionDir);
197
+ if (sub.files === 0 && sub.graderSkipped === 0) return digest;
198
+ digest.metrics.model_usage = mergeModelUsage(
199
+ digest.metrics.model_usage || {}, sub.modelUsage
200
+ );
201
+ digest.metrics.subagent_files = sub.files;
202
+ digest.metrics.subagent_tokens = sub.tokens;
203
+ digest.metrics.subagent_grader_skipped = sub.graderSkipped;
204
+ return digest;
205
+ }
206
+
112
207
  async function uploadSessionOnShip(taskId) {
113
208
  try {
114
209
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
@@ -135,17 +230,7 @@ async function uploadSessionOnShip(taskId) {
135
230
  recordUploadSkip(taskId, decision);
136
231
  return null;
137
232
  }
138
- const raw = fs.readFileSync(transcriptPath, 'utf8');
139
- const entries = [];
140
- for (const line of raw.split('\n')) {
141
- const t = line.trim();
142
- if (!t) continue;
143
- try {
144
- entries.push(JSON.parse(t));
145
- } catch (_) {
146
- /* skip a malformed line — best-effort */
147
- }
148
- }
233
+ const entries = parseJsonl(fs.readFileSync(transcriptPath, 'utf8'));
149
234
  // Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
150
235
  // creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
151
236
  // run's live GDS session token. Defense in depth on top of the pattern +
@@ -157,6 +242,10 @@ async function uploadSessionOnShip(taskId) {
157
242
  const s = await loadSession();
158
243
  if (s && s.token) extraSecrets.push(s.token);
159
244
  const digest = buildSessionDigest(entries, { extraSecrets });
245
+ // task 1003435: the interactive transcript is only part of what this session
246
+ // spent — fold the subagents subtree into model_usage so the cost-plus reward
247
+ // prices the same tokens the cost ledger already bills.
248
+ foldSubagentUsage(digest, transcriptPath, sessionId);
160
249
  // task 1270: surface the session usage so the completion card can show
161
250
  // Claude tokens + an estimated cost. Returned even when the POST below fails
162
251
  // (we already have the digest) — the card is best-effort either way.
@@ -215,4 +304,5 @@ module.exports = {
215
304
  classifyUploadSkip,
216
305
  recordUploadSkip,
217
306
  uploadSessionOnShip,
307
+ foldSubagentUsage,
218
308
  };
@@ -99,7 +99,9 @@ const {
99
99
  resolveInstanceBase,
100
100
  shouldBailOnFetchFailure,
101
101
  } = require('./ship-deploy-target.js');
102
- const { classifyUploadSkip, recordUploadSkip } = require('./ship-session-upload.js');
102
+ const {
103
+ classifyUploadSkip, recordUploadSkip, foldSubagentUsage,
104
+ } = require('./ship-session-upload.js');
103
105
  const {
104
106
  classifyLandOutcome,
105
107
  formatPublishFailure,
@@ -176,6 +178,10 @@ module.exports = {
176
178
  // secret corpus test (tests/scrubber_corpus.mjs) can exercise each layer + tune.
177
179
  shannonEntropy, looksHighEntropySecret, redactHighEntropy, collectEnvSecrets,
178
180
  classifyUploadSkip, recordUploadSkip,
181
+ // task 1003435: the subagents-subtree fold, shared with the SessionEnd hook —
182
+ // both uploads must send the SAME whole-session model_usage or whichever runs
183
+ // last decides what the builder is paid for.
184
+ foldSubagentUsage,
179
185
  // #678: re-grade path + the grade-step helpers it shares with shipMain.
180
186
  // Exporting these formalizes learning #43 (which had to "replicate ~10
181
187
  // lines" because runSubagentGrader wasn't reachable).
@@ -206,13 +206,15 @@ const MODULE_GLOBS = {
206
206
  // (moduleWebSurfaces → serve-internal). Its public read endpoints stay SHARED
207
207
  // core in routes/public.js (the hall + the off-box mirror consume them too).
208
208
  'status-ui': ['modules/status-ui/'],
209
- // The instance's public landing surface (index/projects/create), served by
210
- // platform-server.js from the repo-root public-landing/ dir — not yet a carved
211
- // module, so the glob lists where the code lives TODAY (the auto-tighten
212
- // contract above). Its own key, not provisioning's: the front door is broader
213
- // than instance hosting, and the web-surface precedent (hall-ui/status-ui) is
214
- // one key per surface (task 1002690, goal 1000059).
215
- 'public-landing': ['public-landing/'],
209
+ // Task 1003322 (ADR 0218): the front door graduated to modules/public-landing/ —
210
+ // public-landing/ moved to modules/public-landing/public/ and is now a loader-
211
+ // discovered web-surface module. Where hall-ui and status-ui name a Host PREFIX it
212
+ // declares apexRoot:true, since the apex is whatever host is left once the
213
+ // subdomains are taken; like the hall it keeps its DEDICATED serving block (in
214
+ // platform-server.js, re-pointed at the module dir). The KEY is unchanged from
215
+ // task 1002690 — its own key, not provisioning's, because the front door is broader
216
+ // than instance hosting and the web-surface precedent is one key per surface.
217
+ 'public-landing': ['modules/public-landing/'],
216
218
  // --- the design capability (task 1003324, ADR 0197): the design contract, its
217
219
  // adapters + sync CLIs + validator, the /design playbook, and the reserved homes
218
220
  // for the kit / the vendored UI skills / the style library. default-on,
@@ -211,7 +211,8 @@ function dropEmptyHrefLinks(html, values) {
211
211
  // '<' would otherwise break out of its attribute or close <title> early, and
212
212
  // the values now reach four times as many places as when this was written.
213
213
  // No token is ever emitted into a <script> or a CSS context (checked across
214
- // public-landing/, hall-ui/, status-ui/) — that would need different escaping.
214
+ // public-landing/, hall-ui/, status-ui/ — all under modules/ since task 1003322)
215
+ // — that would need different escaping.
215
216
  // Ampersand first, or the other replacements get double-escaped.
216
217
  function esc(s) {
217
218
  return String(s == null ? '' : s)
@@ -701,9 +702,20 @@ function mountInternalSurfaces(app) {
701
702
  // non-builders host. (The webSurfaces manifest declaration still makes the hall a
702
703
  // loader-DISCOVERED module — it feeds the scope map, the fitness keys, docs, and
703
704
  // window.__MODULES__; it is the dedicated serving below that reads the bytes.)
705
+ //
706
+ // The APEX-ROOT surface (modules/public-landing/, ADR 0218) is partitioned out the
707
+ // same way, on its own declaration rather than on a host: it carries no host prefix
708
+ // at all, because the apex is whatever is LEFT once the subdomains are taken. It is
709
+ // served by platform-server.js's dedicated landing block (the apex-only strip, the
710
+ // brand-token stamp, the ?v= content stamp, the five routes). Keeping it out of
711
+ // isModuleSurfaceHost is load-bearing, not tidiness — that predicate is what the
712
+ // /builders guards below use to step aside, and a surface matching every host would
713
+ // make all of them self-skip.
704
714
  const buildersHostReq = (host) => ({ headers: { host } });
705
715
  const allWebSurfaces = moduleWebSurfaces({ isEnabled: isModuleEnabled });
706
716
  const webSurfaces = allWebSurfaces
717
+ .filter((s) => typeof s.host === 'string') // APEX-ROOT surfaces carry no host (ADR 0218): they are not
718
+ // routed by prefix here, and must stay OUT of isModuleSurfaceHost
707
719
  .filter((s) => !isBuildersHost(buildersHostReq(s.host))) // hall served by the dedicated block, not here
708
720
  .map((s) => ({
709
721
  key: s.key,
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.442'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.444'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -175,32 +175,49 @@ function hallWidgetScripts({ isEnabled } = {}, opts) {
175
175
  return out;
176
176
  }
177
177
 
178
- // Host-routed static web surfaces contributed by enabled modules (BV1.R84, ADR 0093
179
- // §1 / 02-module-map §6 — a web surface is first-class). A module that owns a web
180
- // surface (e.g. the status dashboard at status.<apex>) declares
181
- // contributes.webSurfaces:[{ host:"status.", dir:"public" }] in its manifest AND
182
- // ships that static dir at modules/<key>/<dir>/. This returns, for each discovered +
183
- // ENABLED module that declares webSurfaces and actually ships the directory, the
184
- // SERVING descriptor serve-internal.js iterates to route by Host header:
185
- // { key, host, dir } — host is the lowercase Host-header prefix; dir is the
186
- // ABSOLUTE on-disk path of the static directory.
187
- // The Host-routed twin of mountModuleRoutes / hallWidgetScripts: an enabled module's
188
- // surface answers on its host, a disabled one vanishes (it's never returned, so
189
- // serve-internal never routes its host). No-op (empty list) until a module ships a
190
- // web surface — same SAFE-BY-CONSTRUCTION posture as every other seam here. Pure
191
- // (reads disk only). Exported + module-agnostic so serve-internal hardcodes no key.
178
+ // Static web surfaces contributed by enabled modules (BV1.R84, ADR 0093 §1 /
179
+ // 02-module-map §6 — a web surface is first-class). A module that owns a web surface
180
+ // declares contributes.webSurfaces in its manifest AND ships that static dir at
181
+ // modules/<key>/<dir>/. A surface names WHERE it answers in one of two ways, never
182
+ // both and never neither (ADR 0218):
183
+ // { host: "status.", dir: "public" } → a Host-header PREFIX (status-UI, hall-UI)
184
+ // { apexRoot: true, dir: "public" } → the APEX ROOT (public-landing) — whatever
185
+ // host is left once the subdomains are taken
186
+ // This returns, for each discovered + ENABLED module that declares a surface and
187
+ // actually ships the directory, the SERVING descriptor its server iterates:
188
+ // { key, host, dir } — host is the lowercase Host-header prefix
189
+ // { key, apexRoot: true, dir } — NO host field at all
190
+ // dir is the ABSOLUTE on-disk path of the static directory in both shapes.
191
+ //
192
+ // `host: ""` is REJECTED, not treated as "the root" (ADR 0218 §3): the routers match
193
+ // with host.startsWith(s.host), and every string starts with "" — an empty prefix
194
+ // would answer on builders./status. too and make isModuleSurfaceHost() true for every
195
+ // request, which is the predicate the hall's own /builders guards use to step aside.
196
+ // A boolean cannot be mistaken for a prefix, so apex-root says so with a flag.
197
+ //
198
+ // The twin of mountModuleRoutes / hallWidgetScripts: an enabled module's surface
199
+ // answers, a disabled one vanishes (it's never returned, so nothing routes it — for
200
+ // the apex root that means the front door is simply gone and the 404 tail answers).
201
+ // No-op (empty list) until a module ships a web surface — same SAFE-BY-CONSTRUCTION
202
+ // posture as every other seam here. Pure (reads disk only). Exported + module-agnostic
203
+ // so both servers hardcode no key.
192
204
  function moduleWebSurfaces({ isEnabled } = {}, opts) {
193
205
  const out = [];
194
206
  for (const { key, dir, manifest } of discovered(opts).loaded) {
195
207
  if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
196
208
  const surfaces = (manifest.contributes && manifest.contributes.webSurfaces) || [];
197
209
  for (const s of surfaces) {
198
- if (!s || typeof s.host !== 'string' || typeof s.dir !== 'string') continue;
210
+ if (!s || typeof s.dir !== 'string') continue;
211
+ const apexRoot = s.apexRoot === true;
212
+ const hasHost = typeof s.host === 'string' && s.host !== '';
213
+ // exactly one of the two, never both and never neither — and `host: ""` counts
214
+ // as NEITHER, which is what keeps an empty prefix from matching every host
215
+ if (apexRoot === hasHost) continue;
199
216
  const abs = path.join(dir, s.dir);
200
217
  let isDir = false;
201
218
  try { isDir = fs.statSync(abs).isDirectory(); } catch { isDir = false; }
202
- if (!isDir) continue; // declared but not shipped → skip (no host routed)
203
- out.push({ key, host: s.host.toLowerCase(), dir: abs });
219
+ if (!isDir) continue; // declared but not shipped → skip (nothing routed)
220
+ out.push(apexRoot ? { key, apexRoot: true, dir: abs } : { key, host: s.host.toLowerCase(), dir: abs });
204
221
  }
205
222
  }
206
223
  return out;
@@ -34,7 +34,9 @@
34
34
  // "disciplines": ["artist"], // disciplines this module adds
35
35
  // "skills": ["otb-…"], // slash-command skills it ships
36
36
  // "uiSections": ["boxes"], // hall/status UI sections it owns
37
- // "webSurfaces": [{ "host": "status.", "dir": "public" }], // host-routed static web surfaces (BV1.R84)
37
+ // "webSurfaces": [{ "host": "status.", "dir": "public" }], // static web surfaces. Each names a Host
38
+ // // PREFIX (BV1.R84) or, as { "apexRoot": true, "dir": "public" },
39
+ // // the APEX ROOT — never both (ADR 0218).
38
40
  // "migrations": true // true => modules/<key>/migrations/ exists (or an array of names)
39
41
  // },
40
42
  // "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
@@ -84,13 +86,19 @@ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
84
86
  const isStr = (v) => typeof v === 'string' && v.length > 0;
85
87
  const isStrArray = (v) => Array.isArray(v) && v.every(isStr);
86
88
 
87
- // A webSurface entry: { host: "<subdomain-prefix>.", dir: "<static-dir-rel-to-module>" }.
88
- // `host` is the lowercase Host-header prefix the surface answers on (e.g. "status."
89
- // → status.<apex>); `dir` is the module-relative directory of static files to serve
90
- // (e.g. "public"). Both required, non-empty strings; `dir` must stay inside the
91
- // module (no absolute / "..").
89
+ // A webSurface entry names WHERE the surface answers, in exactly one of two ways
90
+ // (ADR 0218), plus the module-relative directory of static files to serve:
91
+ // { host: "status.", dir: "public" } — a lowercase Host-header PREFIX (status.<apex>)
92
+ // { apexRoot: true, dir: "public" } — the APEX ROOT: whatever host is left once
93
+ // the subdomains are taken (the front door)
94
+ // `dir` is required, non-empty, and must stay inside the module (no absolute / "..").
95
+ // A host surface's `host` must be a NON-EMPTY string: routers match with
96
+ // host.startsWith(host), and every string starts with "", so an empty prefix would
97
+ // answer on every host and silently swallow the other surfaces. That is exactly why
98
+ // the apex root is a boolean flag and not the empty prefix it superficially resembles.
99
+ const isWebSurfaceDir = (v) => isStr(v.dir) && !v.dir.startsWith('/') && !v.dir.split(/[\\/]/).includes('..');
92
100
  const isWebSurface = (v) =>
93
- isObj(v) && isStr(v.host) && isStr(v.dir) && !v.dir.startsWith('/') && !v.dir.split(/[\\/]/).includes('..');
101
+ isObj(v) && isWebSurfaceDir(v) && (v.apexRoot === true ? !('host' in v) : isStr(v.host));
94
102
  const isWebSurfaceArray = (v) => Array.isArray(v) && v.every(isWebSurface);
95
103
 
96
104
  function validateManifest(obj) {
@@ -172,7 +180,7 @@ function validateManifest(obj) {
172
180
  }
173
181
  }
174
182
  if ('webSurfaces' in obj.contributes && !isWebSurfaceArray(obj.contributes.webSurfaces)) {
175
- E('contributes.webSurfaces: must be an array of { host: "<prefix>.", dir: "<module-relative dir>" } objects (dir inside the module, no absolute path or "..")');
183
+ E('contributes.webSurfaces: must be an array of { host: "<prefix>.", dir: "<module-relative dir>" } or { apexRoot: true, dir: "<module-relative dir>" } objects — a surface names a Host prefix OR the apex root, never both and never neither (ADR 0218); dir stays inside the module (no absolute path or "..")');
176
184
  }
177
185
  if ('migrations' in obj.contributes) {
178
186
  const m = obj.contributes.migrations;
@@ -54,13 +54,40 @@ const { logger, requestIdMiddleware } = require('./bongos/logger');
54
54
  const PORT = Number(process.env.PORT) || 3000;
55
55
  const HOST = process.env.HOST || '127.0.0.1';
56
56
 
57
- // The apex landing page — a game-less instance's home (replaces the bare 404 the
58
- // apex host used to serve). A deliberately MINIMAL placeholder: the mascot + a
59
- // way into the Builders' Hall; the full landing is its own task. Served only on
60
- // the apex host (status./builders. are handled by mountInternalSurfaces). The
61
- // branding pack is injected once at boot (theme + window.__BRANDING__), exactly
62
- // as the hall pages are, so the look + identity come from config, not hardcode.
63
- const LANDING_DIR = path.join(__dirname, '..', 'public-landing');
57
+ // The apex front door — a game-less instance's home (it replaced the bare 404 the
58
+ // apex host used to serve). Served only on the apex host; status./builders. are
59
+ // handled by mountInternalSurfaces. The branding pack is injected once at boot
60
+ // (theme + window.__BRANDING__), exactly as the hall pages are, so the look and the
61
+ // identity come from config, never from hardcode.
62
+ //
63
+ // THE SURFACE IS A MODULE (task 1003322, ADR 0218). The front door used to be a
64
+ // hardcoded path here; it is now modules/public-landing/public/, resolved through the
65
+ // loader's web-surface seam by the APEX-ROOT declaration ({ apexRoot: true, dir }) —
66
+ // the one shape that seam grew for it, because the apex is not a Host PREFIX like
67
+ // "status."/"builders." but whatever is left once the subdomains are taken.
68
+ //
69
+ // Resolved by PREDICATE, never by key: this file names no module (ADR 0083 §Decision
70
+ // #4), so an instance shipping its own front door gets the apex by declaring apexRoot.
71
+ // Lazy + memoised so importing this module reads no disk and a test can build the app
72
+ // after setting up its own module state.
73
+ //
74
+ // null when NO enabled module claims the apex root — the module is disabled, or the
75
+ // instance ships no landing at all. Every consumer below already treats an absent page
76
+ // as "fall through", so the front door simply vanishes and the 404 tail answers.
77
+ let _landingDir;
78
+ function landingDir() {
79
+ if (_landingDir === undefined) {
80
+ // The apex root is singular by definition — two modules cannot both be "everything
81
+ // that is not a subdomain". Serve the first and SAY so, rather than silently
82
+ // shadowing one of them.
83
+ const claims = moduleLoader.moduleWebSurfaces({ isEnabled: isModuleEnabled }).filter((s) => s.apexRoot);
84
+ if (claims.length > 1) {
85
+ logger.warn({ keys: claims.map((s) => s.key) }, 'more than one enabled module declares an apex-root web surface — serving the first');
86
+ }
87
+ _landingDir = claims.length ? claims[0].dir : null;
88
+ }
89
+ return _landingDir;
90
+ }
64
91
  // ── CONTENT-STAMPED STATIC ASSETS (task 1002766) ──
65
92
  // The landing HTML is `cf-cache-status: DYNAMIC` and never cached, but the
66
93
  // assets it references come back `Cache-Control: public, max-age=14400` — four
@@ -80,7 +107,7 @@ const LANDING_DIR = path.join(__dirname, '..', 'public-landing');
80
107
  // shape serve-internal.js already stamps onto the hall's own scripts.
81
108
  //
82
109
  // Stamped by RESOLUTION, not by a path allow-list: a URL is an asset if it
83
- // actually names a file under public-landing/. Routes (`/projects`, `/create`)
110
+ // actually names a file under the landing surface. Routes (`/projects`, `/create`)
84
111
  // and API paths resolve to nothing and are left alone, so a new asset is
85
112
  // stamped the day it is added and nobody has to remember to list it.
86
113
  const _assetStamps = new Map();
@@ -88,9 +115,10 @@ function assetStamp(url) {
88
115
  if (!_assetStamps.has(url)) {
89
116
  let v = '';
90
117
  try {
91
- const abs = path.resolve(LANDING_DIR, '.' + url);
118
+ const root = landingDir();
119
+ const abs = path.resolve(root, '.' + url);
92
120
  // never let a template's own text walk out of the landing dir
93
- if (abs.startsWith(LANDING_DIR + path.sep)) {
121
+ if (abs.startsWith(root + path.sep)) {
94
122
  v = require('node:crypto').createHash('sha256')
95
123
  .update(fs.readFileSync(abs)).digest('hex').slice(0, 8);
96
124
  }
@@ -112,14 +140,14 @@ function landingPage(file) {
112
140
  try {
113
141
  // stamped LAST, so anything a later transform introduces is stamped too
114
142
  html = stampAssets(injectBrandingGlobal(stripApexOnly(
115
- fs.readFileSync(path.join(LANDING_DIR, file), 'utf8'))));
116
- } catch { /* absent → '' */ }
143
+ fs.readFileSync(path.join(landingDir(), file), 'utf8'))));
144
+ } catch { /* absent, or no module claims the apex root → '' */ }
117
145
  _landingPages.set(file, html);
118
146
  }
119
147
  return _landingPages.get(file);
120
148
  }
121
149
 
122
- // THE APEX CONDITIONAL (task 1002748). public-landing/ ships wholesale to every
150
+ // THE APEX CONDITIONAL (task 1002748). The landing surface ships wholesale to every
123
151
  // instance, so one file has to render two shapes: the platform's own front door,
124
152
  // and a child project's. Sections that are true only of the platform — the
125
153
  // catalog of OTHER instances, the "capture-resistant commons" claim, the door
@@ -231,11 +259,15 @@ function buildPlatformApp() {
231
259
  res.type('html').send(html);
232
260
  });
233
261
  }
234
- const landingAssets = express.static(LANDING_DIR, { index: false, fallthrough: true });
235
- app.use((req, res, next) => {
236
- if (!isApexHost(req)) return next();
237
- return landingAssets(req, res, next);
238
- });
262
+ // Assets fall through to the 404 tail when no module claims the apex root — the
263
+ // handler is not mounted at all rather than pointed at a missing directory.
264
+ const landingAssets = landingDir() && express.static(landingDir(), { index: false, fallthrough: true });
265
+ if (landingAssets) {
266
+ app.use((req, res, next) => {
267
+ if (!isApexHost(req)) return next();
268
+ return landingAssets(req, res, next);
269
+ });
270
+ }
239
271
 
240
272
  // Non-game tail. Anything that did not resolve to /api/gds, the status host,
241
273
  // or the builders' hall lands here. There is NO game to fall through to, so:
Binary file
@@ -1,6 +1,6 @@
1
1
  // Guards the WCAG AA contrast of the cosmos.css token layer (task 1002749).
2
2
  //
3
- // The tokens are the ONLY input here: this test PARSES public-landing/assets/cosmos.css
3
+ // The tokens are the ONLY input here: this test PARSES the landing surface’s assets/cosmos.css
4
4
  // and resolves var()/color-mix() itself, so it measures what the surfaces actually paint.
5
5
  // Duplicating the hex values would prove nothing.
6
6
  //
@@ -13,10 +13,10 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
- const CSS = path.join(
17
- path.dirname(fileURLToPath(import.meta.url)),
18
- '..', 'public-landing', 'assets', 'cosmos.css'
19
- );
16
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ // the front door is a module since task 1003322 (ADR 0218) — one constant, so the path moves once
18
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
19
+ const CSS = path.join(LANDING, 'assets', 'cosmos.css');
20
20
  const src = fs.readFileSync(CSS, 'utf8');
21
21
 
22
22
  // The two sheets that actually ship, comment-stripped. Several static guards
@@ -76,7 +76,7 @@ const darkMedia = declarations(blockAfter(':root:not([data-mode="light"]){'));
76
76
  // The dark blocks are :root[data-mode="dark"] and :root:not([data-mode="light"]) —
77
77
  // both (0,2,0) — so they outrank the pack's (0,1,0) and win back.
78
78
  const packColors = JSON.parse(
79
- fs.readFileSync(path.join(path.dirname(CSS), '..', '..', 'config', 'branding.neutral.json'), 'utf8')
79
+ fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8')
80
80
  ).theme.ui.colors;
81
81
 
82
82
  // mirrors COLOR_VARS in src/bongos/serve-internal.js