@bongos/core 1.23.21 → 1.24.1

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 (457) hide show
  1. package/.bongos-core.json +983 -428
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +6 -3
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +94 -0
  27. package/clients/bongos-client/index.cjs +94 -0
  28. package/clients/bongos-client/index.d.ts +154 -4
  29. package/clients/bongos-client/index.mjs +94 -0
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
  31. package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
  32. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  33. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  34. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  35. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  36. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  37. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  38. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  39. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  40. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  41. package/docs/adr/README.md +10 -1
  42. package/docs/api/openapi.json +8619 -5885
  43. package/docs/api-reference.md +68 -9
  44. package/docs/architecture.md +37 -1
  45. package/docs/canonical-permissions.md +1 -1
  46. package/docs/copy-inventory.md +420 -337
  47. package/docs/copy-registry.json +1321 -500
  48. package/docs/file-map.md +4 -0
  49. package/docs/module-api-changelog.md +7 -0
  50. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  51. package/docs/onboarding/diagrams/README.md +1 -1
  52. package/docs/onboarding/drachmae-and-karma.md +15 -15
  53. package/docs/onboarding/primer.md +2 -2
  54. package/docs/page-inventory.json +55 -6
  55. package/docs/page-readings.json +1185 -1011
  56. package/docs/recipes/google-sign-in.md +60 -0
  57. package/docs/recipes/ops-gotchas.md +1 -1
  58. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  59. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  60. package/migrations/core_283_payout_approvals.sql +183 -0
  61. package/migrations/core_284_payout_gates.sql +179 -0
  62. package/migrations/core_285_task_needs.sql +74 -0
  63. package/modules/ai-studio/CLAUDE.md +15 -0
  64. package/modules/ai-studio/billing.js +67 -0
  65. package/modules/ai-studio/context.js +29 -0
  66. package/modules/ai-studio/db.js +172 -0
  67. package/modules/ai-studio/gemini.js +75 -0
  68. package/modules/ai-studio/generate.js +119 -0
  69. package/modules/ai-studio/keys.js +73 -0
  70. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  71. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  72. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  73. package/modules/ai-studio/module.json +26 -0
  74. package/modules/ai-studio/openrouter.js +90 -0
  75. package/modules/ai-studio/payments.js +105 -0
  76. package/modules/ai-studio/pricing.js +92 -0
  77. package/modules/ai-studio/routes/ai-studio.js +252 -0
  78. package/modules/ai-studio/studio.js +121 -0
  79. package/modules/autonomy/fence.js +64 -1
  80. package/modules/builder-settings/builder-needs.js +27 -1
  81. package/modules/copy-desk/routes/copy-desk.js +5 -0
  82. package/modules/economy/achievements.js +22 -10
  83. package/modules/economy/cost-classify.js +9 -0
  84. package/modules/economy/cost-write-guard.js +52 -0
  85. package/modules/economy/cost.js +2 -1
  86. package/modules/economy/credits.js +18 -15
  87. package/modules/economy/goal-reward.js +161 -0
  88. package/modules/economy/reward.js +4 -0
  89. package/modules/economy/routes/cost.js +33 -7
  90. package/modules/government/catalog.js +6 -0
  91. package/modules/government/db.js +108 -0
  92. package/modules/government/docket.js +36 -0
  93. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  94. package/modules/government/pollers/board-expiry.js +108 -20
  95. package/modules/government/protected-surfaces.json +1 -0
  96. package/modules/government/routes/government.js +50 -18
  97. package/modules/government/routing.js +39 -5
  98. package/modules/government/waiting.js +74 -0
  99. package/modules/grading/grader-subagent.js +30 -11
  100. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  101. package/modules/hall-ui/public/ai-studio.css +193 -0
  102. package/modules/hall-ui/public/ai-studio.html +98 -0
  103. package/modules/hall-ui/public/ai-studio.js +556 -0
  104. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  105. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  106. package/modules/hall-ui/public/blockers-lib.js +15 -1
  107. package/modules/hall-ui/public/blockers.html +8 -0
  108. package/modules/hall-ui/public/blockers.js +1 -1
  109. package/modules/hall-ui/public/city.html +15 -15
  110. package/modules/hall-ui/public/claim-action.js +3 -0
  111. package/modules/hall-ui/public/collab.html +12 -0
  112. package/modules/hall-ui/public/craft-mode.js +4 -1
  113. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  114. package/modules/hall-ui/public/drachmae.html +1 -1
  115. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  120. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  121. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  122. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  123. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  124. package/modules/hall-ui/public/goal-inbox.js +1 -1
  125. package/modules/hall-ui/public/goals-render.js +4 -0
  126. package/modules/hall-ui/public/goals.html +8 -0
  127. package/modules/hall-ui/public/gov-living.js +6 -2
  128. package/modules/hall-ui/public/hall-ask.css +5 -0
  129. package/modules/hall-ui/public/hall-ask.js +39 -3
  130. package/modules/hall-ui/public/notes-panel.css +20 -0
  131. package/modules/hall-ui/public/notes-panel.js +146 -0
  132. package/modules/hall-ui/public/project-settings.html +1 -1
  133. package/modules/hall-ui/public/project-settings.js +7 -5
  134. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  135. package/modules/hall-ui/public/settings.html +15 -0
  136. package/modules/hall-ui/public/street-deck.js +12 -3
  137. package/modules/hall-ui/public/studio.css +90 -8
  138. package/modules/hall-ui/public/studio.html +39 -10
  139. package/modules/hall-ui/public/studio.js +148 -18
  140. package/modules/hall-ui/public/task.html +2 -0
  141. package/modules/hall-ui/public/task.js +4 -0
  142. package/modules/hall-ui/public/tweak-editor.js +12 -3
  143. package/modules/hall-ui/public/welcome-boot.css +42 -6
  144. package/modules/hall-ui/public/welcome-boot.js +20 -6
  145. package/modules/hall-ui/public/work.js +20 -0
  146. package/modules/hall-ui/records/ai-studio.md +36 -0
  147. package/modules/hall-ui/records/blockers-people.md +24 -0
  148. package/modules/lifecycle/db-analytics.js +10 -3
  149. package/modules/lifecycle/db-claims.js +32 -4
  150. package/modules/lifecycle/db-goals.js +48 -18
  151. package/modules/lifecycle/db-shared.js +9 -0
  152. package/modules/lifecycle/db-tasks.js +15 -6
  153. package/modules/lifecycle/db-versions.js +21 -8
  154. package/modules/lifecycle/done-when.js +23 -1
  155. package/modules/lifecycle/lifecycle.js +5 -0
  156. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  157. package/modules/lifecycle/module.json +2 -1
  158. package/modules/lifecycle/notes.js +219 -0
  159. package/modules/lifecycle/page-notice.js +124 -0
  160. package/modules/lifecycle/page-tweak-artist.js +18 -1
  161. package/modules/lifecycle/publish-reconciler.js +67 -20
  162. package/modules/lifecycle/routes/claims.js +5 -2
  163. package/modules/lifecycle/routes/goals.js +3 -1
  164. package/modules/lifecycle/routes/notes.js +91 -0
  165. package/modules/lifecycle/routes/project-settings.js +43 -35
  166. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  167. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  168. package/modules/lifecycle/routes/tasks.js +12 -12
  169. package/modules/lifecycle/ship-assurance.js +4 -1
  170. package/modules/lifecycle/ship-card.js +8 -2
  171. package/modules/lifecycle/task-needs-gate.js +70 -0
  172. package/modules/market-watch/CLAUDE.md +15 -0
  173. package/modules/market-watch/db.js +340 -0
  174. package/modules/market-watch/key.js +58 -0
  175. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  176. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  177. package/modules/market-watch/module.json +26 -0
  178. package/modules/market-watch/pollers/hub-seed.js +54 -0
  179. package/modules/market-watch/pollers/weekly-run.js +100 -0
  180. package/modules/market-watch/research.js +350 -0
  181. package/modules/market-watch/run.js +245 -0
  182. package/modules/market-watch/seed/market-data.json +38 -0
  183. package/modules/market-watch/seed/partners-data.json +497 -0
  184. package/modules/market-watch/seed.js +252 -0
  185. package/modules/market-watch/store.js +230 -0
  186. package/modules/memory/routes/memory.js +14 -2
  187. package/modules/memory/routes/search.js +8 -2
  188. package/modules/onboarding/howto-moments.js +8 -1
  189. package/modules/platform-identity/account-logins.js +192 -0
  190. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  191. package/modules/platform-identity/platform-identity.js +5 -4
  192. package/modules/platform-identity/routes/my-projects.js +6 -0
  193. package/modules/platform-identity/routes/profile.js +18 -0
  194. package/modules/platform-identity/routes/public-profile.js +10 -1
  195. package/modules/platform-identity/routes/sso.js +26 -1
  196. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  197. package/modules/provisioning/credential-preflight.js +13 -3
  198. package/modules/provisioning/pollers/app-liveness.js +25 -5
  199. package/modules/provisioning/routes/provisioning.js +1 -1
  200. package/modules/provisioning/starter-bundles.js +8 -2
  201. package/modules/public-landing/public/account.html +10 -2
  202. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  203. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  205. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  209. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  210. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  213. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  214. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  221. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  222. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  223. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  224. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  225. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  226. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  227. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  228. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  229. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  230. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  233. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  244. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  245. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  246. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  247. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  248. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  249. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  250. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  251. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  252. package/modules/public-landing/public/community.html +1 -1
  253. package/modules/public-landing/public/connections.html +2 -2
  254. package/modules/public-landing/public/guild.html +1 -1
  255. package/modules/public-landing/public/invitations.html +2 -2
  256. package/modules/public-landing/public/projects.html +5 -5
  257. package/modules/raise/views/nda.html +2 -0
  258. package/modules/specialities/db.js +32 -1
  259. package/modules/specialities/routes/specialities.js +10 -6
  260. package/modules/status-ui/public/index.html +1 -1
  261. package/package-lock.json +2 -2
  262. package/package.json +1 -1
  263. package/release-notes.json +286 -0
  264. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  265. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  266. package/scripts/bongos/claim.js +22 -1
  267. package/scripts/bongos/db-backup-nightly.sh +29 -0
  268. package/scripts/bongos/deploy-divergence.js +7 -0
  269. package/scripts/bongos/doc-cli-guard.js +6 -0
  270. package/scripts/bongos/fitness.js +3 -2
  271. package/scripts/bongos/gen-diagrams.js +3 -3
  272. package/scripts/bongos/lib.sh +4 -5
  273. package/scripts/bongos/migration-namespace.js +13 -1
  274. package/scripts/bongos/plain-cards.js +4 -0
  275. package/scripts/bongos/provision-github-setup.js +3 -1
  276. package/scripts/bongos/provision-units.js +6 -1
  277. package/scripts/bongos/publish-manifest.js +4 -0
  278. package/scripts/bongos/release.js +2 -0
  279. package/scripts/bongos/run-unit-tests.js +268 -82
  280. package/scripts/bongos/seed-tasks.example.json +56 -0
  281. package/scripts/bongos/seed-tasks.js +222 -0
  282. package/scripts/bongos/seed-verify-criteria.js +1 -2
  283. package/scripts/bongos/ship-finish.js +2 -0
  284. package/scripts/bongos/ship-resume.js +2 -0
  285. package/scripts/bongos/strand-lease.js +250 -0
  286. package/scripts/bongos/strand-watch.js +129 -12
  287. package/scripts/bongos/version-literal-guard.js +2 -2
  288. package/scripts/discord/post.js +4 -3
  289. package/scripts/gds/seed-tasks.js +7 -0
  290. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  291. package/scripts/hall-preview/server.js +21 -0
  292. package/src/bongos/auth-config.js +29 -1
  293. package/src/bongos/auth-discord.js +3 -3
  294. package/src/bongos/auth-github.js +3 -6
  295. package/src/bongos/auth-google.js +217 -0
  296. package/src/bongos/device-approval.js +140 -0
  297. package/src/bongos/llm-pricing.js +17 -1
  298. package/src/bongos/metrics.js +48 -2
  299. package/src/bongos/middleware/rate-limit.js +30 -11
  300. package/src/bongos/module-scope-map.js +16 -1
  301. package/src/bongos/optimizer.js +15 -1
  302. package/src/bongos/payout-approvals.js +342 -0
  303. package/src/bongos/payout-gates.js +364 -0
  304. package/src/bongos/prelaunch-gate.js +7 -9
  305. package/src/bongos/prelaunch-switch.js +20 -0
  306. package/src/bongos/route-rank-check.js +3 -0
  307. package/src/bongos/routes/auth-google.js +246 -0
  308. package/src/bongos/routes/auth.js +38 -1
  309. package/src/bongos/routes/backup.js +33 -9
  310. package/src/bongos/routes/builders.js +57 -30
  311. package/src/bongos/routes/github.js +9 -0
  312. package/src/bongos/routes/me.js +18 -0
  313. package/src/bongos/routes/my-payouts.js +133 -0
  314. package/src/bongos/routes/payout-approvals.js +312 -0
  315. package/src/bongos/routes/payout-gates.js +179 -0
  316. package/src/bongos/routes.js +15 -1
  317. package/src/bongos/secret-box.js +18 -3
  318. package/src/bongos/serve-internal.js +47 -19
  319. package/src/bongos/sign-in-pages.js +138 -0
  320. package/src/bongos/task-needs.js +146 -0
  321. package/src/bongos-downloads.js +18 -1
  322. package/src/instance-config.js +28 -0
  323. package/src/module-api.js +14 -1
  324. package/src/platform-server.js +27 -1
  325. package/tests/account_logins.mjs +326 -0
  326. package/tests/account_logins_db.mjs +161 -0
  327. package/tests/ai_studio_billing.mjs +253 -0
  328. package/tests/ai_studio_generate.mjs +282 -0
  329. package/tests/ai_studio_keys.mjs +159 -0
  330. package/tests/ai_studio_requests.mjs +319 -0
  331. package/tests/auth_backchannel_logout_route.mjs +201 -0
  332. package/tests/auto_satisfy_criteria.mjs +10 -1
  333. package/tests/autobongos_tweak_lane.mjs +144 -0
  334. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  335. package/tests/backfill_subagent_rewards.mjs +17 -1
  336. package/tests/backup_trigger_lock.mjs +119 -0
  337. package/tests/batched_writes_parity.mjs +340 -0
  338. package/tests/blockers_people.mjs +198 -0
  339. package/tests/bounded_rate_maps.mjs +184 -0
  340. package/tests/claimable_blocker_status.mjs +1 -1
  341. package/tests/claims_batch_routes.mjs +421 -0
  342. package/tests/conductor_main_e2e.mjs +4 -0
  343. package/tests/config_read_cache.mjs +171 -0
  344. package/tests/copy_desk_page_editor.mjs +12 -0
  345. package/tests/cost_write_guard.mjs +200 -0
  346. package/tests/craft_achievements.mjs +33 -10
  347. package/tests/criterion_goal_attach.mjs +1 -1
  348. package/tests/db_backup_nightly.mjs +73 -1
  349. package/tests/deploy_divergence_line.mjs +7 -0
  350. package/tests/effective_visibility_predicate.mjs +12 -0
  351. package/tests/fitness.mjs +19 -0
  352. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  353. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  354. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  355. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  356. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  357. package/tests/github_one_trip.mjs +34 -0
  358. package/tests/github_repo_visibility.mjs +20 -0
  359. package/tests/goal_achievement.mjs +30 -1
  360. package/tests/goal_achievement_reward.mjs +205 -0
  361. package/tests/goal_archive_disposition.mjs +5 -5
  362. package/tests/goal_sort_staleness.mjs +2 -1
  363. package/tests/goal_tier.mjs +11 -1
  364. package/tests/google_fonts_nonblocking.mjs +84 -0
  365. package/tests/google_sign_in.mjs +401 -0
  366. package/tests/government_board_expiry.mjs +94 -0
  367. package/tests/government_protected_surfaces.mjs +1 -1
  368. package/tests/government_routes.mjs +146 -23
  369. package/tests/grader.mjs +31 -0
  370. package/tests/hall_ai_studio.mjs +347 -0
  371. package/tests/hall_city.mjs +8 -0
  372. package/tests/hall_page_gate_map.mjs +3 -0
  373. package/tests/hall_studio_home.mjs +61 -2
  374. package/tests/home_attention_cards.mjs +1 -1
  375. package/tests/hook_shell_tools.mjs +8 -0
  376. package/tests/hooks_drain.mjs +1 -1
  377. package/tests/host_topology_skips.mjs +2 -0
  378. package/tests/howto_moments.mjs +17 -0
  379. package/tests/human_signal.mjs +141 -0
  380. package/tests/install_script_reader.mjs +51 -0
  381. package/tests/integration_fixture_ids.mjs +82 -0
  382. package/tests/invite_expiry_db.mjs +13 -1
  383. package/tests/lib_sh_resolution.mjs +0 -4
  384. package/tests/market_watch_data.mjs +244 -0
  385. package/tests/market_watch_data_db.mjs +308 -0
  386. package/tests/market_watch_module.mjs +114 -0
  387. package/tests/market_watch_run.mjs +503 -0
  388. package/tests/metrics.mjs +124 -0
  389. package/tests/migration_namespace.mjs +14 -0
  390. package/tests/module_api.mjs +2 -0
  391. package/tests/module_loader.mjs +1 -1
  392. package/tests/module_skills_hook.mjs +69 -0
  393. package/tests/notes.mjs +182 -0
  394. package/tests/own_scope_guard.mjs +5 -0
  395. package/tests/page_tweak_marker_sql.mjs +76 -0
  396. package/tests/page_tweak_no_block.mjs +135 -0
  397. package/tests/page_tweak_notice.mjs +103 -0
  398. package/tests/payout_approvals.mjs +253 -0
  399. package/tests/payout_approvals_db.mjs +106 -0
  400. package/tests/payout_gates.mjs +322 -0
  401. package/tests/payout_gates_db.mjs +113 -0
  402. package/tests/planning_session_skill.mjs +13 -3
  403. package/tests/project_settings_page.mjs +29 -5
  404. package/tests/project_settings_spend_cap_route.mjs +235 -0
  405. package/tests/promotion_sweep.mjs +29 -1
  406. package/tests/provision.mjs +3 -2
  407. package/tests/provisioning_app_status.mjs +18 -0
  408. package/tests/provisioning_credential_preflight.mjs +53 -0
  409. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  410. package/tests/public_landing_projects.mjs +37 -0
  411. package/tests/publish_reconciler_overlap.mjs +60 -0
  412. package/tests/rank_change_broadcast_port.mjs +151 -0
  413. package/tests/seed_tasks.mjs +176 -0
  414. package/tests/seed_verify_criteria.mjs +2 -6
  415. package/tests/session_start_freshness.mjs +66 -1
  416. package/tests/shell_hooks.mjs +209 -0
  417. package/tests/ship_assurance.mjs +28 -0
  418. package/tests/ship_card.mjs +20 -5
  419. package/tests/site_classified_once.mjs +142 -0
  420. package/tests/speciality_db_shape.mjs +23 -0
  421. package/tests/speciality_session_skills.mjs +48 -2
  422. package/tests/strand_lease.mjs +249 -0
  423. package/tests/strand_watch.mjs +58 -0
  424. package/tests/task_criteria_routes.mjs +275 -0
  425. package/tests/task_criteria_unlink_db.mjs +123 -0
  426. package/tests/task_needs.mjs +233 -0
  427. package/tests/task_needs_db.mjs +110 -0
  428. package/tests/unit_runner_serial.mjs +266 -43
  429. package/tests/version_close_route.mjs +1 -1
  430. package/tests/version_literals.mjs +5 -7
  431. package/tests/welcome_boot.mjs +16 -5
  432. package/tests/welcome_boot_browser.mjs +36 -4
  433. package/docs/recipes/bongos-cli-release.md +0 -66
  434. package/scripts/bongos/backfill-disciplines.js +0 -144
  435. package/scripts/bongos/backfill-session-earnings.js +0 -123
  436. package/scripts/bongos/backfill-session-types.js +0 -87
  437. package/scripts/bongos/build-bongos.js +0 -94
  438. package/scripts/bongos/cleanup-orphans.js +0 -74
  439. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  440. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  441. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  442. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  443. package/scripts/bongos/smoke-batch-release.sh +0 -316
  444. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  445. package/scripts/bongos/smoke-dependencies.sh +0 -143
  446. package/scripts/gds/backfill-disciplines.js +0 -7
  447. package/scripts/gds/backfill-session-earnings.js +0 -7
  448. package/scripts/gds/backfill-session-types.js +0 -7
  449. package/scripts/gds/cleanup-orphans.js +0 -7
  450. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  451. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  452. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  453. package/scripts/gds/smoke-batch-claim.sh +0 -8
  454. package/scripts/gds/smoke-batch-release.sh +0 -8
  455. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  456. package/scripts/gds/smoke-dependencies.sh +0 -8
  457. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -104,7 +104,7 @@ test('every /governance route gates via REAL middleware on exactly its PINNED at
104
104
  // task 1004712: the treasury is read, and its pots and borrows decided, where the room is (the holder and the seat are the walls).
105
105
  // task 1004715: the strictness presets are read where the law book is.
106
106
  // task 1004716: the counts are read where the docket they count is.
107
- const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
107
+ const boardFamily = (key) => key.includes('/board/') || key.includes('/government/asks') || key.endsWith('/government/constitution') || key.endsWith('/government/docket') || key.includes('/government/blockers/') || key.endsWith('/government/counts') || key.endsWith('/government/laws') || key.endsWith('/government/leads') || key.endsWith('/government/buildouts') || key.includes('/government/treasury') || key.endsWith('/government/presets');
108
108
  for (const r of classified.filter((x) => !boardFamily(x.key))) {
109
109
  assert.deepEqual(r.perms, ['government.manage'], `${r.key}: the admin surface gates on the manage atom`);
110
110
  }
@@ -149,6 +149,9 @@ const stub = {
149
149
  }),
150
150
  createRank: async (a) => ({ rank_key: a.rankKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
151
151
  grantRankPermission: async () => true,
152
+ // task 1004929: the rank-set apply's one-transaction write. The apply tests
153
+ // swap in the REAL function over a fake SQL store (withRealRankDb below).
154
+ applyRankSet: async () => new Map(),
152
155
  revokeRankPermission: async () => true,
153
156
  resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
154
157
  // task 1004481: `deleteAnswer` scripts what the locked delete found.
@@ -265,35 +268,155 @@ test('GET /government/assignments returns builder↔role rows', async () => {
265
268
 
266
269
  // --- recommended rank sets (task 1004400) -------------------------------------
267
270
 
271
+ // task 1004929: the apply is now ONE transaction written by db.applyRankSet. These
272
+ // tests run the REAL route into the REAL db functions, over a small fake of the
273
+ // two tables that understands exactly the statements the old per-row loop and
274
+ // the new batched write send. That is what lets the parity test below run the
275
+ // old loop (tests/fixtures/per-row-loops-legacy.cjs, verbatim) and the new route
276
+ // over the same starting tables and compare the replies AND what was written.
277
+ //
278
+ // `existing` are ranks already there; `race` are ranks another caller creates
279
+ // between this call's read and its insert (invisible to the read, a conflict on
280
+ // the insert) — the case the old loop caught as a 23505.
281
+ function rankStore({ existing = [], race = [], failOnGrant = false } = {}) {
282
+ let st = { ranks: existing.map((k) => ({ rank_key: k, label: 'Theirs', description: null, created_by: 7 })), grants: [] };
283
+ let snap = null;
284
+ const raced = new Set(race);
285
+ const queries = [];
286
+ const has = (k) => st.ranks.some((r) => r.rank_key === k);
287
+ const conflicts = (k) => {
288
+ if (raced.has(k)) { raced.delete(k); st.ranks.push({ rank_key: k, label: 'Raced', description: null, created_by: 8 }); }
289
+ return has(k);
290
+ };
291
+ const addRank = (k, label, description, createdBy) => {
292
+ const row = { rank_key: k, label, description: description ?? null, created_by: Number(createdBy) };
293
+ st.ranks.push(row);
294
+ return { ...row, is_system: false, seeded_rank: null, created_at: 't' };
295
+ };
296
+ const addGrant = (rk, pk) => {
297
+ if (failOnGrant) throw new Error('grant insert failed');
298
+ if (st.grants.some((g) => g.rank_key === rk && g.permission_key === pk)) return false;
299
+ st.grants.push({ rank_key: rk, permission_key: pk });
300
+ return true;
301
+ };
302
+ async function query(sql, p = []) {
303
+ const s = String(sql).replace(/\s+/g, ' ').trim();
304
+ queries.push(s);
305
+ if (s === 'BEGIN') { snap = JSON.parse(JSON.stringify(st)); return { rows: [] }; }
306
+ if (s === 'COMMIT') { snap = null; return { rows: [] }; }
307
+ if (s === 'ROLLBACK') { if (snap) st = snap; snap = null; return { rows: [] }; }
308
+ if (/^SELECT rank_key, label, description, is_system, seeded_rank, created_by, created_at FROM government_ranks WHERE rank_key = \$1$/.test(s)) {
309
+ return { rows: st.ranks.filter((r) => r.rank_key === p[0]).map((r) => ({ ...r, is_system: false, seeded_rank: null })) };
310
+ }
311
+ if (/^INSERT INTO government_ranks \(.*\) VALUES \(\$1, \$2, \$3, false, NULL, \$4\) RETURNING/.test(s)) {
312
+ if (conflicts(p[0])) throw Object.assign(new Error('duplicate key'), { code: '23505' });
313
+ return { rows: [addRank(p[0], p[1], p[2], p[3])] };
314
+ }
315
+ if (/^INSERT INTO government_rank_permissions \(rank_key, permission_key\) VALUES \(\$1, \$2\) ON CONFLICT/.test(s)) {
316
+ return { rows: [], rowCount: addGrant(p[0], p[1]) ? 1 : 0 };
317
+ }
318
+ if (/^SELECT rank_key FROM government_ranks WHERE rank_key = ANY\(\$1::text\[\]\)$/.test(s)) {
319
+ return { rows: st.ranks.filter((r) => p[0].includes(r.rank_key)).map((r) => ({ rank_key: r.rank_key })) };
320
+ }
321
+ if (/^INSERT INTO government_ranks .* FROM unnest\(\$1::text\[\], \$2::text\[\], \$3::text\[\]\) WITH ORDINALITY .* ORDER BY r\.ord ON CONFLICT DO NOTHING RETURNING rank_key$/.test(s)) {
322
+ const rows = [];
323
+ p[0].forEach((k, i) => { if (!conflicts(k)) { addRank(k, p[1][i], p[2][i], p[3]); rows.push({ rank_key: k }); } });
324
+ return { rows };
325
+ }
326
+ if (/^INSERT INTO government_rank_permissions .* FROM unnest\(\$1::text\[\], \$2::text\[\]\) WITH ORDINALITY .* ORDER BY g\.ord ON CONFLICT \(rank_key, permission_key\) DO NOTHING RETURNING rank_key, permission_key$/.test(s)) {
327
+ const rows = [];
328
+ p[0].forEach((rk, i) => { if (addGrant(rk, p[1][i])) rows.push({ rank_key: rk, permission_key: p[1][i] }); });
329
+ return { rows };
330
+ }
331
+ throw new Error(`rank store: unhandled statement: ${s.slice(0, 120)}`);
332
+ }
333
+ return { query, client: { query, release() {} }, queries, get state() { return JSON.parse(JSON.stringify(st)); } };
334
+ }
335
+
336
+ // Point the REAL government db functions at a rankStore for the length of `fn`.
337
+ const pgPool = require(path.join(ROOT, 'src', 'bongos', 'pool.js')).pool;
338
+ const RANK_DB_FNS = ['getRankByKey', 'createRank', 'grantRankPermission', 'applyRankSet'];
339
+ async function withRealRankDb(store, fn) {
340
+ const saved = Object.fromEntries(RANK_DB_FNS.map((k) => [k, stub[k]]));
341
+ const own = { query: Object.prototype.hasOwnProperty.call(pgPool, 'query'), connect: Object.prototype.hasOwnProperty.call(pgPool, 'connect') };
342
+ const realQuery = pgPool.query;
343
+ const realConnect = pgPool.connect;
344
+ for (const k of RANK_DB_FNS) stub[k] = REAL[k];
345
+ pgPool.query = store.query;
346
+ pgPool.connect = async () => store.client;
347
+ try { return await fn(); } finally {
348
+ Object.assign(stub, saved);
349
+ if (own.query) pgPool.query = realQuery; else delete pgPool.query;
350
+ if (own.connect) pgPool.connect = realConnect; else delete pgPool.connect;
351
+ }
352
+ }
353
+
268
354
  test('POST /government/rank-sets/apply creates the missing ranks with their grants and leaves existing ones alone', async () => {
269
- const realGet = stub.getRankByKey;
270
355
  // 'design-lead' already exists: it must be skipped, never re-granted.
271
- stub.getRankByKey = async (k) => (k === 'design-lead' ? { rank_key: k, label: 'Mine', is_system: false, seeded_rank: null } : null);
272
- try {
273
- const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
274
- assert.equal(r.status, 200);
275
- assert.deepEqual(r.json.skipped, [{ rank_key: 'design-lead', label: 'Design lead' }], 'a kept rank is named by its label, not its key (task 1004478)');
276
- assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
277
- const creates = calls.filter(([k]) => k === 'createRank');
278
- assert.equal(creates.length, 1);
279
- assert.equal(creates[0][1].createdBy, 42, 'created_by is the session builder');
280
- const grants = calls.filter(([k]) => k === 'grantRankPermission').map(([, a]) => a);
281
- assert.ok(grants.length > 0 && grants.every((g) => g.rankKey === 'playtester'), 'an existing rank is never re-granted');
282
- assert.ok(grants.every((g) => catalog.isGrantableTo(g.permissionKey, { is_system: false, seeded_rank: null })));
283
- } finally { stub.getRankByKey = realGet; }
356
+ const store = rankStore({ existing: ['design-lead'] });
357
+ const r = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 }));
358
+ assert.equal(r.status, 200);
359
+ assert.deepEqual(r.json.skipped, [{ rank_key: 'design-lead', label: 'Design lead' }], 'a kept rank is named by its label, not its key (task 1004478)');
360
+ assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
361
+ const { ranks, grants } = store.state;
362
+ assert.deepEqual(ranks.map((x) => [x.rank_key, x.label, x.created_by]), [['design-lead', 'Theirs', 7], ['playtester', 'Playtester', 42]],
363
+ 'the kept rank is untouched and created_by is the session builder');
364
+ assert.ok(grants.length > 0 && grants.every((g) => g.rank_key === 'playtester'), 'an existing rank is never re-granted');
365
+ assert.ok(grants.every((g) => catalog.isGrantableTo(g.permission_key, { is_system: false, seeded_rank: null })));
366
+ assert.deepEqual(r.json.created[0].permissions, grants.map((g) => g.permission_key), 'the reply names exactly what was granted');
367
+ assert.equal(store.queries.filter((q) => q === 'BEGIN').length, 1, 'one transaction');
368
+ assert.equal(store.queries.filter((q) => /^INSERT/.test(q)).length, 2, 'one rank insert and one grant insert, however many grants');
284
369
  });
285
370
 
286
371
  test('POST /government/rank-sets/apply needs a headcount and does nothing at founding size', async () => {
287
372
  const bad = await call('POST', '/government/rank-sets/apply', { type: 'game' });
288
373
  assert.equal(bad.status, 400);
289
- const realGet = stub.getRankByKey;
290
- stub.getRankByKey = async () => null;
291
- try {
292
- const solo = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 });
293
- assert.equal(solo.status, 200);
294
- assert.deepEqual(solo.json.created, []);
295
- assert.equal(calls.filter(([k]) => k === 'createRank').length, 0);
296
- } finally { stub.getRankByKey = realGet; }
374
+ const store = rankStore();
375
+ const solo = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 }));
376
+ assert.equal(solo.status, 200);
377
+ assert.deepEqual(solo.json.created, []);
378
+ assert.deepEqual(store.queries, [], 'an empty set touches nothing');
379
+ });
380
+
381
+ test('POST /government/rank-sets/apply: a failure part-way writes NOTHING (one transaction)', async () => {
382
+ const store = rankStore({ failOnGrant: true });
383
+ const r = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'business', headcount: 200 }));
384
+ assert.equal(r.status, 500);
385
+ assert.deepEqual(r.json.error.details, { created: [], skipped: [] }, 'nothing was written, so nothing is reported as added');
386
+ assert.deepEqual(store.state, { ranks: [], grants: [] }, 'the ranks inserted before the failure are rolled back');
387
+ assert.ok(store.queries.includes('ROLLBACK'));
388
+ });
389
+
390
+ test('POST /government/rank-sets/apply: the batched write gives the SAME reply and tables as the old per-row loop', async () => {
391
+ const legacy = require(path.join(ROOT, 'tests', 'fixtures', 'per-row-loops-legacy.cjs'));
392
+ const recommendations = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
393
+ const realDb = { getRankByKey: REAL.getRankByKey, createRank: REAL.createRank, grantRankPermission: REAL.grantRankPermission };
394
+ let compared = 0;
395
+ for (const { key: type } of recommendations.PROJECT_TYPES) {
396
+ for (const headcount of [1, 5, 20, 80, 200]) {
397
+ const keys = recommendations.recommendedRankSet(type, headcount).ranks.map((x) => x.rank_key);
398
+ const scenarios = {
399
+ 'nothing there yet': {},
400
+ 'the first rank exists': { existing: keys.slice(0, 1) },
401
+ 'every rank exists': { existing: keys },
402
+ 'the last rank is created by someone else mid-call': { race: keys.slice(-1) },
403
+ };
404
+ for (const [name, opts] of Object.entries(scenarios)) {
405
+ const label = `${type} × ${headcount}: ${name}`;
406
+ const oldStore = rankStore(opts);
407
+ const old = await withRealRankDb(oldStore, () => legacy.legacyApplyRankSet({
408
+ db: realDb, catalog, rankSet: recommendations.recommendedRankSet(type, headcount), builderId: 42,
409
+ }));
410
+ const newStore = rankStore(opts);
411
+ const neu = await withRealRankDb(newStore, () => call('POST', '/government/rank-sets/apply', { type, headcount }));
412
+ assert.equal(neu.status, 200, label);
413
+ assert.deepEqual({ created: neu.json.created, skipped: neu.json.skipped }, old, `${label}: the same reply`);
414
+ assert.deepEqual(newStore.state, oldStore.state, `${label}: the same ranks and grants written, in the same order`);
415
+ compared += 1;
416
+ }
417
+ }
418
+ }
419
+ assert.ok(compared >= 20, `compared ${compared} cases`);
297
420
  });
298
421
 
299
422
  // --- create role -------------------------------------------------------------
package/tests/grader.mjs CHANGED
@@ -789,6 +789,37 @@ test('buildContextBundle reads changed files once, skips binaries/missing, respe
789
789
  assert.equal(bundle.total_chars, files['src/a.js'].length + files['docs/b.md'].length);
790
790
  });
791
791
 
792
+ // task 1004932 — the bundle rides in EVERY worker prompt, so its total is capped at
793
+ // 60k chars (was 120k), and it is filled most-changed first so the cap drops the
794
+ // lightly touched files, never the heart of the change.
795
+ const CAP_FILE = 15_000; // under the 16k per-file cap, so nothing truncates
796
+ function capFiles(names) { return Object.fromEntries(names.map((n) => [n, `// ${n}\n` + 'x'.repeat(CAP_FILE)])); }
797
+ function diffAdding(counts) {
798
+ return Object.entries(counts).map(([f, n]) =>
799
+ `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n@@ -1 +1,${n} @@\n` +
800
+ Array.from({ length: n }, (_, i) => `+const line_${i}_of_${f.replace(/\W/g, '_')} = ${i};`).join('\n')).join('\n');
801
+ }
802
+
803
+ test('buildContextBundle caps the shared bundle at 60k chars (task 1004932)', () => {
804
+ const names = ['a.js', 'b.js', 'c.js', 'd.js', 'e.js'];
805
+ const files = capFiles(names);
806
+ const bundle = grader.buildContextBundle({ changedFiles: names, cwd: '/r', fsImpl: fakeFsFor(files) });
807
+ assert.ok(bundle.total_chars <= 60_000, `bundle is ${bundle.total_chars} chars — over the 60k cap`);
808
+ assert.ok(bundle.total_chars > 45_000, 'the cap is not lower than 60k: three ~15k files still fit');
809
+ assert.deepEqual(bundle.files.map((f) => f.file), ['a.js', 'b.js', 'c.js'], 'no diff: the caller order is kept');
810
+ assert.deepEqual(bundle.skipped, ['d.js', 'e.js'], 'what does not fit is named in skipped, never silent');
811
+ });
812
+
813
+ test('buildContextBundle fills most-changed first, so the cap drops the lightly touched files (task 1004932)', () => {
814
+ const names = ['docs/index.md', 'docs/map.md', 'tests/x.mjs', 'README.md', 'src/heart.js'];
815
+ const files = capFiles(names);
816
+ const diff = diffAdding({ 'docs/index.md': 1, 'docs/map.md': 1, 'tests/x.mjs': 20, 'README.md': 2, 'src/heart.js': 60 });
817
+ const bundle = grader.buildContextBundle({ changedFiles: names, cwd: '/r', diff, fsImpl: fakeFsFor(files) });
818
+ assert.deepEqual(bundle.files.map((f) => f.file), ['src/heart.js', 'tests/x.mjs', 'README.md'],
819
+ 'the file the diff is mostly about comes first, even when it is listed last');
820
+ assert.deepEqual(bundle.skipped, ['docs/index.md', 'docs/map.md'], 'ties keep their listed order, and one-line touches are what falls off');
821
+ });
822
+
792
823
  test('buildContextBundle truncates oversized files and marks them', () => {
793
824
  const big = 'x'.repeat(20_000);
794
825
  const bundle = grader.buildContextBundle({
@@ -0,0 +1,347 @@
1
+ // tests/hall_ai_studio.mjs — the studio's AI pages, /builders/ai-studio (task
2
+ // 1004921, goal 1000136, ADR 0392), built on option C of the AI Studio options
3
+ // canvas plus the owner's addition: the result's request can be changed and
4
+ // made again.
5
+ //
6
+ // Three halves:
7
+ // 1. The pure rules (ai-studio-lib.js): money in words (sub-cent prices to
8
+ // four decimals, a balance rounded down), the receipt in its three forms,
9
+ // the month's summary, the waiting checklist, the route, the reply's safe
10
+ // markup, and the home's line.
11
+ // 2. The REAL page (ai-studio.js) in a vm over a small fake DOM and a stub
12
+ // client that answers from the hall-preview fixtures (the same shapes the
13
+ // ai-studio routes send): each view paints its words, step 3 sends the
14
+ // request on the card pressed, and "Make it again" sends the CHANGED words
15
+ // on fresh copies of the earlier uploads, paid the same way.
16
+ // 3. The walls: the page writes through three routes only, never asks for a
17
+ // key, and the studio home shows its AI door only where the module is on.
18
+
19
+ import { test } from 'node:test';
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
30
+ const FIX = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
31
+ const lib = require(path.join(PUB, 'ai-studio-lib.js'));
32
+ const { escapeHtml } = require(path.join(ROOT, 'src', 'html-escape.js'));
33
+ const fixture = (name) => JSON.parse(fs.readFileSync(path.join(FIX, `${name}.json`), 'utf8'));
34
+ // The harness writes relative dates ("@-0.1d"); here every one is an hour ago.
35
+ const HOUR_AGO = new Date(Date.now() - 3600 * 1000).toISOString();
36
+ const dated = (v) => JSON.parse(JSON.stringify(v), (k, x) => (typeof x === 'string' && /^@[-+]/.test(x) ? HOUR_AGO : x));
37
+
38
+ // ---- 1. the pure rules --------------------------------------------------------
39
+
40
+ test('money: sub-cent prices keep four decimals, whole cents two, a balance rounds down', () => {
41
+ assert.equal(lib.usd(42100), '$0.0421');
42
+ assert.equal(lib.usd(500000), '$0.50');
43
+ assert.equal(lib.usd(20910000), '$20.91');
44
+ assert.equal(lib.usd(-2600000), '−$2.60');
45
+ assert.equal(lib.usd(3100), '$0.0031');
46
+ assert.equal(lib.balanceUsd(12357900), '$12.35', 'a balance never shows a cent that is not there');
47
+ assert.equal(lib.balanceUsd(12400000), '$12.40');
48
+ assert.equal(lib.usd(null), '');
49
+ });
50
+
51
+ test('the receipt: our key splits the provider and our markup; an own key charges nothing', () => {
52
+ const done = dated(fixture('ai-studio__requests__done')).request;
53
+ const ours = lib.receipt(done, 1.08);
54
+ assert.deepEqual(ours.rows, [['Paid with', 'your balance'], ['OpenRouter', '$0.0390'], ['Our 8%', '$0.0031'], ['You paid', '$0.0421']]);
55
+ const gemini = lib.receipt(dated(fixture('ai-studio__requests__own')).request, 1.08);
56
+ assert.deepEqual(gemini.rows, [['Paid with', 'your own Gemini key'], ['Cost', 'not reported']]);
57
+ assert.match(gemini.note, /We charged nothing/);
58
+ const openrouter = lib.receipt({ status: 'done', key_source: 'own', provider_cost_micro: 39000 }, 1.08);
59
+ assert.deepEqual(openrouter.rows, [['Paid with', 'your own OpenRouter key'], ['Cost', '$0.0390']]);
60
+ assert.deepEqual(lib.receipt(dated(fixture('ai-studio__requests__failed')).request, 1.08).rows, [], 'a failed run has no receipt');
61
+ });
62
+
63
+ test('the month: added, spent, money back and own-key requests, from the ledger', () => {
64
+ const bal = dated(fixture('ai-studio__balance'));
65
+ const sum = lib.monthSummary(bal.ledger, new Date());
66
+ assert.deepEqual(sum.lines.map((l) => [l.label, l.value]), [
67
+ ['Added', '+$15.00'],
68
+ ['Spent on 2 requests', '−$0.0833'],
69
+ ['Money back from 1 failed request', '$0.50 returned'],
70
+ ['On your own keys, 1 request', 'free'],
71
+ ]);
72
+ const old = lib.monthSummary([{ kind: 'topup', amount_micro: 5000000, created_at: '2001-01-01T00:00:00Z' }], new Date());
73
+ assert.deepEqual(old.lines, [], 'another month does not count');
74
+ });
75
+
76
+ test('the waiting lines follow the status and the clock; the clock reads m:ss', () => {
77
+ assert.deepEqual(lib.waitSteps('queued', 0, 3).map((s) => s.state), ['now', 'later', 'later']);
78
+ assert.deepEqual(lib.waitSteps('running', 5, 3).map((s) => s.state), ['done', 'now', 'later']);
79
+ assert.deepEqual(lib.waitSteps('running', lib.ANSWER_AFTER_SEC, 1).map((s) => [s.text, s.state]), [
80
+ ['Read your file', 'done'], ['Wrote the answer', 'done'], ['Drawing the pictures', 'now'],
81
+ ]);
82
+ assert.equal(lib.clock(34), '0:34');
83
+ assert.equal(lib.clock(75), '1:15');
84
+ });
85
+
86
+ test('the route, the files, the reply and the home line', () => {
87
+ assert.deepEqual(lib.routeOf('?request=1002'), { view: 'request', id: '1002' });
88
+ assert.deepEqual(lib.routeOf('?view=balance&all=1'), { view: 'balance', all: true });
89
+ assert.deepEqual(lib.routeOf('?step=make&from=7'), { view: 'step', step: 'make', from: '7' });
90
+ assert.deepEqual(lib.routeOf('?step=nope&from=x'), { view: 'step', step: 'pick', from: null });
91
+ assert.equal(lib.checkFile({ type: 'image/png', size: 100 }, 0), null);
92
+ assert.match(lib.checkFile({ type: 'image/svg+xml', size: 100 }, 0), /PNG, JPG/);
93
+ assert.match(lib.checkFile({ type: 'image/png', size: lib.MAX_IMAGE_BYTES + 1 }, 0), /4 MB/);
94
+ assert.match(lib.checkFile({ type: 'application/pdf', size: 100 }, lib.MAX_FILES), /up to 6/);
95
+ assert.equal(lib.replyHtml('Hi **there**\n\n<script>x</script>', escapeHtml), '<p>Hi <b>there</b></p><p>&lt;script&gt;x&lt;/script&gt;</p>');
96
+ assert.equal(lib.replyHtml('<b>x</b>'), '', 'no escaper, no markup: never unescaped text');
97
+ assert.equal(lib.homeLine([{ id: '9', status: 'running' }]).href, '/builders/ai-studio?request=9');
98
+ assert.equal(lib.homeLine([{ id: '9', status: 'done', finished_at: new Date().toISOString() }]).detail, 'your newest pictures are ready');
99
+ assert.equal(lib.homeLine([]).detail, 'sketches in, ideas and pictures out');
100
+ assert.equal(lib.againLine(3), 'Change the words and make it again. Same 3 files, paid the same way.');
101
+ });
102
+
103
+ test('step 3 offers the own key only when one is saved, and says when the balance is short', () => {
104
+ const keys = fixture('ai-studio__keys');
105
+ const bal = dated(fixture('ai-studio__balance'));
106
+ const c = lib.payChoices(keys, bal);
107
+ assert.deepEqual([c.own.name, c.own.billedBy, c.own.last4, c.left, c.setAside, c.short], ['Gemini', 'Google', '9c1d', '$12.40 left', '$0.50', false]);
108
+ // OpenRouter wins when both are saved, as keys.chooseKey prefers it. (Built
109
+ // here: the harness scrubber blanks that provider name out of fixtures.)
110
+ const both = { keys: [{ provider: 'openrouter', set: true, last4: '4f2a' }, { provider: 'gemini', set: true, last4: '9c1d' }] };
111
+ assert.deepEqual([lib.ownKey(both).name, lib.ownKey(both).billedBy, lib.ownKey(both).last4], ['OpenRouter', 'OpenRouter', '4f2a']);
112
+ assert.equal(lib.payChoices({ keys: [] }, bal).own, null);
113
+ assert.equal(lib.payChoices(keys, { ...bal, balance: { available_micro: 100000, held_micro: 0 } }).short, true);
114
+ assert.equal(lib.payChoices(keys, { ...bal, balance: null }).payments, false);
115
+ });
116
+
117
+ // ---- 2. the real page in a vm ------------------------------------------------
118
+
119
+ // A fake element: enough of the DOM for ai-studio.js. innerHTML is kept as a
120
+ // string, and the elements a view wires are found in that string by id or by
121
+ // data attribute, so a click runs the page's real handler.
122
+ function makeEl(id) {
123
+ const el = {
124
+ id, _html: '', value: '', hidden: false, disabled: false, attrs: {}, handlers: {}, children: {},
125
+ classList: { add() {}, remove() {} },
126
+ get innerHTML() { return el._html; },
127
+ set innerHTML(v) { el._html = String(v); el.children = {}; },
128
+ get innerText() { return el._html.replace(/<[^>]+>/g, ' '); },
129
+ setAttribute(k, v) { el.attrs[k] = String(v); },
130
+ getAttribute(k) { return k in el.attrs ? el.attrs[k] : null; },
131
+ removeAttribute(k) { delete el.attrs[k]; },
132
+ addEventListener(type, fn) { (el.handlers[type] = el.handlers[type] || []).push(fn); },
133
+ focus() {},
134
+ async fire(type, extra = {}) {
135
+ const ev = { preventDefault() {}, currentTarget: el, target: el, ...extra };
136
+ for (const fn of el.handlers[type] || []) await fn(ev);
137
+ },
138
+ querySelector(sel) {
139
+ const id2 = /^#([\w-]+)$/.exec(sel);
140
+ if (id2 && el._html.includes(`id="${id2[1]}"`)) return el.children[sel] || (el.children[sel] = makeEl(id2[1]));
141
+ if (sel === '[data-head]' && el._html.includes('data-head')) return el.children[sel] || (el.children[sel] = makeEl('head'));
142
+ return null;
143
+ },
144
+ querySelectorAll(sel) {
145
+ const attr = /^\[(data-[\w-]+)\]$/.exec(sel);
146
+ if (!attr) return [];
147
+ const key = `all:${sel}`;
148
+ if (!el.children[key]) {
149
+ const re = new RegExp(`${attr[1]}="([^"]*)"`, 'g');
150
+ el.children[key] = [...el._html.matchAll(re)].map((m) => { const c = makeEl(''); c.attrs[attr[1]] = m[1]; return c; });
151
+ }
152
+ return el.children[key];
153
+ },
154
+ };
155
+ return el;
156
+ }
157
+
158
+ const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
159
+
160
+ function bootPage(search, { routes = {} } = {}) {
161
+ const els = { 'ais-page': makeEl('ais-page'), 'hall-main': makeEl('hall-main'), 'ais-file': makeEl('ais-file') };
162
+ const calls = [];
163
+ const store = new Map();
164
+ const loc = { search, href: `/builders/ai-studio${search}` };
165
+ const answers = {
166
+ 'GET /api/bongos/me': { builder: { id: 1 } },
167
+ 'GET /api/bongos/ai-studio/status': { ok: true },
168
+ 'GET /api/bongos/ai-studio/keys': fixture('ai-studio__keys'),
169
+ 'GET /api/bongos/ai-studio/balance': dated(fixture('ai-studio__balance')),
170
+ 'GET /api/bongos/ai-studio/requests': dated(fixture('ai-studio__requests')),
171
+ 'GET /api/bongos/ai-studio/requests/1001': dated(fixture('ai-studio__requests__done')),
172
+ 'GET /api/bongos/ai-studio/requests/1002': dated(fixture('ai-studio__requests__running')),
173
+ 'GET /api/bongos/ai-studio/requests/1003': dated(fixture('ai-studio__requests__failed')),
174
+ 'GET /api/bongos/ai-studio/requests/1004': dated(fixture('ai-studio__requests__own')),
175
+ 'GET /api/bongos/ai-studio/topup/quote': fixture('ai-studio__topup__quote'),
176
+ 'POST /api/bongos/ai-studio/requests': dated(fixture('ai-studio__requests.post')),
177
+ 'POST /api/bongos/ai-studio/topup': fixture('ai-studio__topup.post'),
178
+ ...routes,
179
+ };
180
+ let uploadSeq = 700;
181
+ const client = {
182
+ async request(method, url, opts = {}) {
183
+ calls.push({ method, url, body: opts.body });
184
+ if (method === 'POST' && url === '/api/bongos/ai-studio/uploads') {
185
+ uploadSeq += 1;
186
+ return { ok: true, status: 201, data: { file: { id: String(uploadSeq), role: 'upload', name: opts.body.name, content_type: opts.body.content_type, size_bytes: 10, url: `/api/bongos/ai-studio/files/${uploadSeq}` } } };
187
+ }
188
+ const key = `${method} ${url.split('?')[0]}`;
189
+ if (!(key in answers)) return { ok: false, status: 404, data: { error: { code: 'not_found' } } };
190
+ const a = answers[key];
191
+ if (a && a.__status) return { ok: false, status: a.__status, data: a.body };
192
+ return { ok: true, status: 200, data: a };
193
+ },
194
+ };
195
+ const ctx = {
196
+ console,
197
+ URLSearchParams,
198
+ setTimeout,
199
+ clearTimeout,
200
+ setInterval: () => 0,
201
+ clearInterval: () => {},
202
+ Date,
203
+ Promise,
204
+ document: { getElementById: (id) => els[id] || null, body: makeEl('body') },
205
+ location: loc,
206
+ history: {
207
+ pushState(_s, _t, url) { loc.href = url; loc.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; },
208
+ replaceState(_s, _t, url) { loc.href = url; loc.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; },
209
+ },
210
+ sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
211
+ fetch: async (url) => { calls.push({ method: 'FETCH', url }); return { ok: true, blob: async () => ({ size: 3, type: 'image/png' }) }; },
212
+ FileReader: class { readAsDataURL() { this.result = 'data:image/png;base64,QUJD'; setImmediate(() => this.onload()); } },
213
+ addEventListener() {},
214
+ };
215
+ ctx.window = ctx;
216
+ ctx.window.BongosClient = { createClient: () => client };
217
+ ctx.window.OTB = { toast: () => {}, escapeHtml };
218
+ vm.createContext(ctx);
219
+ for (const f of ['studio-lib.js', 'ai-studio-lib.js', 'ai-studio.js']) {
220
+ const src = f === 'studio-lib.js' ? 'window.OTBStudioLib = { skyAt: () => "night" };' : fs.readFileSync(path.join(PUB, f), 'utf8');
221
+ vm.runInContext(src, ctx, { filename: f });
222
+ }
223
+ return { els, page: els['ais-page'], main: els['hall-main'], calls, loc, store };
224
+ }
225
+
226
+ test('page: step 1 opens on an earlier request\'s files, carried by address, with Next', async () => {
227
+ const p = bootPage('?step=pick&from=1001');
228
+ await settle();
229
+ assert.match(p.page.innerHTML, /What are you starting from\?/);
230
+ assert.match(p.page.innerHTML, /hero-sketch\.png/);
231
+ assert.match(p.page.innerHTML, /mood-board\.pdf/);
232
+ assert.match(p.page.innerHTML, /3 files ready/);
233
+ assert.match(p.page.innerHTML, /1 Pick<\/span>/);
234
+ assert.equal(p.main.getAttribute('data-view'), 'pick');
235
+ });
236
+
237
+ test('page: step 3 sends the request on the card pressed, with copies of the carried files', async () => {
238
+ const p = bootPage('?step=make&from=1001');
239
+ await settle();
240
+ assert.match(p.page.innerHTML, /How do you want to pay\?/);
241
+ assert.match(p.page.innerHTML, /Gemini key ending 9c1d/);
242
+ assert.match(p.page.innerHTML, /Free here\. Google bills you for it directly\./);
243
+ assert.match(p.page.innerHTML, /Up to \$0\.50 is set aside, and the rest comes back\./);
244
+ assert.match(p.page.innerHTML, /\$12\.40 left/);
245
+ const ours = p.page.querySelectorAll('[data-pay]').find((b) => b.getAttribute('data-pay') === 'ours');
246
+ await ours.fire('click');
247
+ await settle();
248
+ const uploads = p.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/uploads'));
249
+ assert.equal(uploads.length, 3, 'a file goes with one request only, so each carried file is uploaded again');
250
+ const sent = p.calls.find((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/requests'));
251
+ assert.equal(sent.body.pay_with, 'ours');
252
+ assert.equal(JSON.stringify(sent.body.file_ids), JSON.stringify(['701', '702', '703']));
253
+ assert.equal(p.loc.search, '?request=1002', 'the request\'s own page opens');
254
+ assert.match(p.page.innerHTML, /usually done in about a minute/);
255
+ assert.equal(p.main.getAttribute('data-making'), 'on', 'the lamp is on while it runs');
256
+ });
257
+
258
+ test('page: the result shows the picture, the receipt, and makes it again with the CHANGED words', async () => {
259
+ const p = bootPage('?request=1001');
260
+ await settle();
261
+ const html = p.page.innerHTML;
262
+ assert.match(html, /class="ais-big" src="\/api\/bongos\/ai-studio\/files\/511"/);
263
+ assert.match(html, /lit it from the lamp<\/b>/);
264
+ assert.match(html, /Your request/);
265
+ assert.match(html, /Same 3 files, paid the same way\./);
266
+ assert.match(html, /Our 8%/);
267
+ assert.match(html, /\$0\.0421/);
268
+ assert.equal(p.main.getAttribute('data-view'), 'result');
269
+ const box = p.page.querySelector('#ais-again');
270
+ assert.match(box.value, /^Turn the sketch/, 'the box holds the request as it was sent');
271
+ box.value = 'Warmer lamp, pinker glow.';
272
+ await p.page.querySelector('#ais-remake').fire('click');
273
+ await settle();
274
+ const sent = p.calls.find((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/requests'));
275
+ assert.equal(sent.body.prompt, 'Warmer lamp, pinker glow.');
276
+ assert.equal(sent.body.pay_with, 'ours', 'paid the same way');
277
+ assert.equal(sent.body.file_ids.length, 3);
278
+ const fetched = p.calls.filter((c) => c.method === 'FETCH').map((c) => c.url);
279
+ assert.deepEqual(fetched, ['/api/bongos/ai-studio/files/501', '/api/bongos/ai-studio/files/502', '/api/bongos/ai-studio/files/503'], 'only the uploads are copied, never the outputs');
280
+ });
281
+
282
+ test('page: "Change files" carries the request\'s files and the edited words into step 1', async () => {
283
+ const p = bootPage('?request=1004');
284
+ await settle();
285
+ assert.match(p.page.innerHTML, /your own Gemini key/);
286
+ p.page.querySelector('#ais-again').value = 'Same, but at dawn.';
287
+ await p.page.querySelector('#ais-change').fire('click');
288
+ await settle();
289
+ assert.equal(p.loc.search, '?step=pick');
290
+ assert.match(p.page.innerHTML, /3 files ready/);
291
+ const draft = JSON.parse(p.store.get('otb.ai-studio.draft'));
292
+ assert.equal(draft.prompt, 'Same, but at dawn.');
293
+ });
294
+
295
+ test('page: a failed run says why, shows no receipt, and keeps the request to try again', async () => {
296
+ const p = bootPage('?request=1003');
297
+ await settle();
298
+ assert.match(p.page.innerHTML, /did not answer in time\. You were not charged\./);
299
+ assert.doesNotMatch(p.page.innerHTML, /ais-receipt/);
300
+ assert.match(p.page.innerHTML, /Same file, paid the same way\./);
301
+ });
302
+
303
+ test('page: the balance shows what is left, the card charge before paying, and the month', async () => {
304
+ const p = bootPage('?view=balance');
305
+ await settle();
306
+ const html = p.page.innerHTML;
307
+ assert.match(html, /\$12\.40<\/span> <span class="ais-soft">left/);
308
+ assert.match(html, /Add how much\?/);
309
+ assert.match(html, /Test mode: no real money moves\./);
310
+ assert.match(html, /Money back from 1 failed request/);
311
+ assert.equal(p.page.querySelector('#ais-quote').textContent, '$20.00 lands in your balance; your card is charged $20.91.');
312
+ await p.page.querySelector('#ais-add').fire('click');
313
+ await settle();
314
+ const top = p.calls.find((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/topup'));
315
+ assert.equal(JSON.stringify(top.body), JSON.stringify({ amount_usd: 20 })); // vm-born: compare through JSON
316
+ });
317
+
318
+ test('page: the module off says so, and calls nothing else', async () => {
319
+ const p = bootPage('?step=pick', { routes: { 'GET /api/bongos/ai-studio/status': { __status: 404, body: {} } } });
320
+ await settle();
321
+ assert.match(p.page.innerHTML, /not switched on for this project/);
322
+ assert.ok(!p.calls.some((c) => c.url.includes('/ai-studio/keys') || c.method === 'POST'));
323
+ });
324
+
325
+ // ---- 3. the walls -------------------------------------------------------------
326
+
327
+ test('walls: the page writes through three routes only, and never asks for a key', () => {
328
+ const js = fs.readFileSync(path.join(PUB, 'ai-studio.js'), 'utf8');
329
+ const html = fs.readFileSync(path.join(PUB, 'ai-studio.html'), 'utf8');
330
+ const writes = [...js.matchAll(/api\.request\('(POST|PUT|PATCH|DELETE)',\s*([^,]+),/g)].map((m) => m[2].trim());
331
+ assert.ok(writes.length >= 3);
332
+ for (const w of writes) assert.match(w, /^WRITE_ROUTES\.(upload|request|topup)\(\)$/, `a write outside the allowlist: ${w}`);
333
+ const block = /const WRITE_ROUTES = Object\.freeze\(\{([\s\S]*?)\}\);/.exec(js)[1];
334
+ assert.deepEqual([...block.matchAll(/^\s*(\w+):/gm)].map((m) => m[1]), ['upload', 'request', 'topup']);
335
+ assert.doesNotMatch(js + html, /type="password"|ai-studio\/keys\/|\bkey:\s*[a-z]/i, 'a key is entered only in Settings');
336
+ assert.match(js, /\/builders\/settings#ai-keys/, 'the page points at Settings for a key');
337
+ });
338
+
339
+ test('walls: the studio home shows the AI door only where the module is on', () => {
340
+ const js = fs.readFileSync(path.join(PUB, 'studio.js'), 'utf8');
341
+ const html = fs.readFileSync(path.join(PUB, 'studio.html'), 'utf8');
342
+ assert.match(html, /<a class="st-action" id="st-ai" href="\/builders\/ai-studio" hidden>/);
343
+ assert.match(html, /<span class="st-action__label">Make something with AI<\/span>/);
344
+ assert.match(html, /id="st-ai-balance"[^>]*hidden/);
345
+ assert.match(js, /mods\.enabled\['ai-studio'\] !== true\) return;/, 'off by default: a missing flag hides the door');
346
+ assert.match(js, /loadAi\(\)\]/);
347
+ });
@@ -289,6 +289,14 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
289
289
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
290
290
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
291
291
  assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, as on every hall page');
292
+ // task 1004925: every script but dom-utils.js is deferred, so none holds up the
293
+ // parser; defer runs them in document order, which is why the order above still
294
+ // holds. dom-utils.js stays synchronous: the one-escaper guard
295
+ // (scripts/bongos/escape-helper-guard.js) requires window.OTB before anything runs.
296
+ const tags = [...html.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]);
297
+ assert.equal(tags.length, scripts.length, 'no inline script runs ahead of the deferred ones');
298
+ assert.doesNotMatch(tags[0], /\b(defer|async)\b/, 'dom-utils.js loads synchronously, first');
299
+ for (const tag of tags.slice(1)) assert.match(tag, /\sdefer(?=[\s>])/, `${tag} is deferred`);
292
300
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
293
301
  assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
294
302
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
@@ -74,6 +74,9 @@ const ORACLE = {
74
74
  // task 1004321 (BV2.TW10): the tweak editor, on the studio's own tier. Its read
75
75
  // is any-builder like the .docx download; every write is the page holder's only.
76
76
  '/builders/tweak-editor': 'requireBuilderPage',
77
+ // task 1004921 (goal 1000136): the studio's AI pages, on the studio's tier.
78
+ // Every read and write behind it is the viewer's own (the ai-studio routes).
79
+ '/builders/ai-studio': 'requireBuilderPage',
77
80
  '/builders/ideas': 'requireBuilderPage',
78
81
  // THE ROOM (task 1004098 / BV2.R03) — a builder's own idea trail. Sign-in and
79
82
  // no rank floor, the tier /ideas sits on: there is no permission gate on