@bongos/core 1.23.22 → 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 (424) hide show
  1. package/.bongos-core.json +902 -407
  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 +2 -2
  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 +90 -0
  27. package/clients/bongos-client/index.cjs +90 -0
  28. package/clients/bongos-client/index.d.ts +149 -4
  29. package/clients/bongos-client/index.mjs +90 -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/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  33. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  34. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  35. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  36. package/docs/adr/README.md +5 -1
  37. package/docs/api/openapi.json +8555 -5876
  38. package/docs/api-reference.md +63 -10
  39. package/docs/architecture.md +37 -1
  40. package/docs/canonical-permissions.md +1 -1
  41. package/docs/copy-inventory.md +420 -337
  42. package/docs/copy-registry.json +1321 -500
  43. package/docs/file-map.md +1 -0
  44. package/docs/module-api-changelog.md +5 -0
  45. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  46. package/docs/onboarding/diagrams/README.md +1 -1
  47. package/docs/onboarding/drachmae-and-karma.md +15 -15
  48. package/docs/onboarding/primer.md +2 -2
  49. package/docs/page-inventory.json +55 -6
  50. package/docs/page-readings.json +1185 -1011
  51. package/docs/recipes/google-sign-in.md +60 -0
  52. package/docs/recipes/ops-gotchas.md +1 -1
  53. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  54. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  55. package/migrations/core_283_payout_approvals.sql +183 -0
  56. package/migrations/core_284_payout_gates.sql +179 -0
  57. package/migrations/core_285_task_needs.sql +74 -0
  58. package/modules/ai-studio/billing.js +67 -0
  59. package/modules/ai-studio/context.js +29 -0
  60. package/modules/ai-studio/db.js +172 -0
  61. package/modules/ai-studio/gemini.js +75 -0
  62. package/modules/ai-studio/generate.js +119 -0
  63. package/modules/ai-studio/keys.js +73 -0
  64. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  65. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  66. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  67. package/modules/ai-studio/openrouter.js +90 -0
  68. package/modules/ai-studio/payments.js +105 -0
  69. package/modules/ai-studio/pricing.js +92 -0
  70. package/modules/ai-studio/routes/ai-studio.js +235 -5
  71. package/modules/ai-studio/studio.js +121 -0
  72. package/modules/autonomy/fence.js +64 -1
  73. package/modules/builder-settings/builder-needs.js +27 -1
  74. package/modules/copy-desk/routes/copy-desk.js +5 -0
  75. package/modules/economy/achievements.js +22 -10
  76. package/modules/economy/cost-classify.js +9 -0
  77. package/modules/economy/cost-write-guard.js +52 -0
  78. package/modules/economy/cost.js +2 -1
  79. package/modules/economy/credits.js +18 -15
  80. package/modules/economy/goal-reward.js +161 -0
  81. package/modules/economy/reward.js +4 -0
  82. package/modules/economy/routes/cost.js +33 -7
  83. package/modules/government/catalog.js +6 -0
  84. package/modules/government/db.js +34 -0
  85. package/modules/government/docket.js +36 -0
  86. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  87. package/modules/government/pollers/board-expiry.js +108 -20
  88. package/modules/government/protected-surfaces.json +1 -0
  89. package/modules/government/routes/government.js +22 -0
  90. package/modules/government/routing.js +39 -5
  91. package/modules/government/waiting.js +74 -0
  92. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  93. package/modules/hall-ui/public/ai-studio.css +193 -0
  94. package/modules/hall-ui/public/ai-studio.html +98 -0
  95. package/modules/hall-ui/public/ai-studio.js +556 -0
  96. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  97. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  98. package/modules/hall-ui/public/blockers-lib.js +15 -1
  99. package/modules/hall-ui/public/blockers.html +8 -0
  100. package/modules/hall-ui/public/blockers.js +1 -1
  101. package/modules/hall-ui/public/claim-action.js +3 -0
  102. package/modules/hall-ui/public/collab.html +12 -0
  103. package/modules/hall-ui/public/craft-mode.js +4 -1
  104. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  105. package/modules/hall-ui/public/drachmae.html +1 -1
  106. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  107. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  108. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  109. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  110. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  111. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  112. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  113. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  114. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  115. package/modules/hall-ui/public/goal-inbox.js +1 -1
  116. package/modules/hall-ui/public/goals-render.js +4 -0
  117. package/modules/hall-ui/public/goals.html +8 -0
  118. package/modules/hall-ui/public/gov-living.js +6 -2
  119. package/modules/hall-ui/public/hall-ask.css +5 -0
  120. package/modules/hall-ui/public/hall-ask.js +39 -3
  121. package/modules/hall-ui/public/notes-panel.css +20 -0
  122. package/modules/hall-ui/public/notes-panel.js +146 -0
  123. package/modules/hall-ui/public/project-settings.html +1 -1
  124. package/modules/hall-ui/public/project-settings.js +7 -5
  125. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  126. package/modules/hall-ui/public/settings.html +15 -0
  127. package/modules/hall-ui/public/street-deck.js +12 -3
  128. package/modules/hall-ui/public/studio.css +90 -8
  129. package/modules/hall-ui/public/studio.html +39 -10
  130. package/modules/hall-ui/public/studio.js +148 -18
  131. package/modules/hall-ui/public/task.html +2 -0
  132. package/modules/hall-ui/public/task.js +4 -0
  133. package/modules/hall-ui/public/tweak-editor.js +12 -3
  134. package/modules/hall-ui/public/welcome-boot.css +42 -6
  135. package/modules/hall-ui/public/welcome-boot.js +20 -6
  136. package/modules/hall-ui/public/work.js +20 -0
  137. package/modules/hall-ui/records/ai-studio.md +36 -0
  138. package/modules/hall-ui/records/blockers-people.md +24 -0
  139. package/modules/lifecycle/db-claims.js +32 -4
  140. package/modules/lifecycle/db-goals.js +18 -5
  141. package/modules/lifecycle/db-tasks.js +15 -6
  142. package/modules/lifecycle/done-when.js +23 -1
  143. package/modules/lifecycle/lifecycle.js +5 -0
  144. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  145. package/modules/lifecycle/module.json +2 -1
  146. package/modules/lifecycle/notes.js +219 -0
  147. package/modules/lifecycle/page-notice.js +124 -0
  148. package/modules/lifecycle/page-tweak-artist.js +18 -1
  149. package/modules/lifecycle/publish-reconciler.js +67 -20
  150. package/modules/lifecycle/routes/claims.js +5 -2
  151. package/modules/lifecycle/routes/goals.js +3 -1
  152. package/modules/lifecycle/routes/notes.js +91 -0
  153. package/modules/lifecycle/routes/project-settings.js +43 -35
  154. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  155. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  156. package/modules/lifecycle/routes/tasks.js +12 -12
  157. package/modules/lifecycle/task-needs-gate.js +70 -0
  158. package/modules/market-watch/CLAUDE.md +15 -0
  159. package/modules/market-watch/db.js +340 -0
  160. package/modules/market-watch/key.js +58 -0
  161. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  162. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  163. package/modules/market-watch/module.json +26 -0
  164. package/modules/market-watch/pollers/hub-seed.js +54 -0
  165. package/modules/market-watch/pollers/weekly-run.js +100 -0
  166. package/modules/market-watch/research.js +350 -0
  167. package/modules/market-watch/run.js +245 -0
  168. package/modules/market-watch/seed/market-data.json +38 -0
  169. package/modules/market-watch/seed/partners-data.json +497 -0
  170. package/modules/market-watch/seed.js +252 -0
  171. package/modules/market-watch/store.js +230 -0
  172. package/modules/memory/routes/memory.js +14 -2
  173. package/modules/memory/routes/search.js +8 -2
  174. package/modules/platform-identity/account-logins.js +192 -0
  175. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  176. package/modules/platform-identity/platform-identity.js +5 -4
  177. package/modules/platform-identity/routes/my-projects.js +6 -0
  178. package/modules/platform-identity/routes/profile.js +18 -0
  179. package/modules/platform-identity/routes/public-profile.js +10 -1
  180. package/modules/platform-identity/routes/sso.js +26 -1
  181. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  182. package/modules/provisioning/credential-preflight.js +13 -3
  183. package/modules/provisioning/pollers/app-liveness.js +25 -5
  184. package/modules/provisioning/routes/provisioning.js +1 -1
  185. package/modules/provisioning/starter-bundles.js +6 -2
  186. package/modules/public-landing/public/account.html +10 -2
  187. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  188. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  189. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  190. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  191. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  192. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  193. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  194. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  195. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  196. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  197. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  198. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  199. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  200. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  201. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  202. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  203. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  204. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  205. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  206. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  207. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  208. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  209. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  210. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  211. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  212. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  213. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  214. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  215. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  216. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  217. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  218. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  219. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  220. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  221. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  222. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  223. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  224. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  225. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  226. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  227. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  228. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  229. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  230. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  231. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  232. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  233. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  234. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  235. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  236. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  237. package/modules/public-landing/public/community.html +1 -1
  238. package/modules/public-landing/public/connections.html +2 -2
  239. package/modules/public-landing/public/guild.html +1 -1
  240. package/modules/public-landing/public/invitations.html +2 -2
  241. package/modules/public-landing/public/projects.html +5 -5
  242. package/modules/raise/views/nda.html +2 -0
  243. package/modules/specialities/db.js +32 -1
  244. package/modules/specialities/routes/specialities.js +10 -6
  245. package/modules/status-ui/public/index.html +1 -1
  246. package/package-lock.json +2 -2
  247. package/package.json +1 -1
  248. package/release-notes.json +220 -0
  249. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  250. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  251. package/scripts/bongos/claim.js +22 -1
  252. package/scripts/bongos/db-backup-nightly.sh +29 -0
  253. package/scripts/bongos/deploy-divergence.js +7 -0
  254. package/scripts/bongos/doc-cli-guard.js +6 -0
  255. package/scripts/bongos/fitness.js +3 -2
  256. package/scripts/bongos/gen-diagrams.js +3 -3
  257. package/scripts/bongos/lib.sh +4 -5
  258. package/scripts/bongos/migration-namespace.js +13 -1
  259. package/scripts/bongos/plain-cards.js +4 -0
  260. package/scripts/bongos/provision-github-setup.js +3 -1
  261. package/scripts/bongos/provision-units.js +6 -1
  262. package/scripts/bongos/publish-manifest.js +3 -0
  263. package/scripts/bongos/release.js +2 -0
  264. package/scripts/bongos/run-unit-tests.js +268 -82
  265. package/scripts/bongos/seed-tasks.example.json +56 -0
  266. package/scripts/bongos/seed-tasks.js +222 -0
  267. package/scripts/bongos/seed-verify-criteria.js +1 -2
  268. package/scripts/bongos/ship-finish.js +2 -0
  269. package/scripts/bongos/ship-resume.js +2 -0
  270. package/scripts/bongos/strand-lease.js +250 -0
  271. package/scripts/bongos/strand-watch.js +26 -5
  272. package/scripts/bongos/version-literal-guard.js +2 -2
  273. package/scripts/discord/post.js +4 -3
  274. package/scripts/gds/seed-tasks.js +7 -0
  275. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  276. package/scripts/hall-preview/server.js +21 -0
  277. package/src/bongos/auth-config.js +29 -1
  278. package/src/bongos/auth-discord.js +3 -3
  279. package/src/bongos/auth-github.js +3 -6
  280. package/src/bongos/auth-google.js +217 -0
  281. package/src/bongos/device-approval.js +140 -0
  282. package/src/bongos/llm-pricing.js +17 -1
  283. package/src/bongos/metrics.js +48 -2
  284. package/src/bongos/middleware/rate-limit.js +30 -11
  285. package/src/bongos/module-scope-map.js +12 -1
  286. package/src/bongos/optimizer.js +15 -1
  287. package/src/bongos/payout-approvals.js +342 -0
  288. package/src/bongos/payout-gates.js +364 -0
  289. package/src/bongos/prelaunch-gate.js +7 -9
  290. package/src/bongos/prelaunch-switch.js +20 -0
  291. package/src/bongos/route-rank-check.js +3 -0
  292. package/src/bongos/routes/auth-google.js +246 -0
  293. package/src/bongos/routes/auth.js +38 -1
  294. package/src/bongos/routes/backup.js +33 -9
  295. package/src/bongos/routes/builders.js +57 -30
  296. package/src/bongos/routes/github.js +9 -0
  297. package/src/bongos/routes/me.js +18 -0
  298. package/src/bongos/routes/my-payouts.js +133 -0
  299. package/src/bongos/routes/payout-approvals.js +312 -0
  300. package/src/bongos/routes/payout-gates.js +179 -0
  301. package/src/bongos/routes.js +15 -1
  302. package/src/bongos/secret-box.js +18 -3
  303. package/src/bongos/serve-internal.js +47 -19
  304. package/src/bongos/sign-in-pages.js +138 -0
  305. package/src/bongos/task-needs.js +146 -0
  306. package/src/bongos-downloads.js +18 -1
  307. package/src/instance-config.js +28 -0
  308. package/src/module-api.js +14 -1
  309. package/tests/account_logins.mjs +326 -0
  310. package/tests/account_logins_db.mjs +161 -0
  311. package/tests/ai_studio_billing.mjs +253 -0
  312. package/tests/ai_studio_generate.mjs +282 -0
  313. package/tests/ai_studio_keys.mjs +159 -0
  314. package/tests/ai_studio_requests.mjs +319 -0
  315. package/tests/auth_backchannel_logout_route.mjs +201 -0
  316. package/tests/auto_satisfy_criteria.mjs +10 -1
  317. package/tests/autobongos_tweak_lane.mjs +144 -0
  318. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  319. package/tests/backfill_subagent_rewards.mjs +17 -1
  320. package/tests/backup_trigger_lock.mjs +119 -0
  321. package/tests/blockers_people.mjs +198 -0
  322. package/tests/bounded_rate_maps.mjs +184 -0
  323. package/tests/claimable_blocker_status.mjs +1 -1
  324. package/tests/claims_batch_routes.mjs +421 -0
  325. package/tests/config_read_cache.mjs +171 -0
  326. package/tests/copy_desk_page_editor.mjs +12 -0
  327. package/tests/cost_write_guard.mjs +200 -0
  328. package/tests/craft_achievements.mjs +33 -10
  329. package/tests/criterion_goal_attach.mjs +1 -1
  330. package/tests/db_backup_nightly.mjs +73 -1
  331. package/tests/deploy_divergence_line.mjs +7 -0
  332. package/tests/effective_visibility_predicate.mjs +12 -0
  333. package/tests/fitness.mjs +19 -0
  334. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  335. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  336. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  337. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  338. package/tests/github_one_trip.mjs +34 -0
  339. package/tests/github_repo_visibility.mjs +20 -0
  340. package/tests/goal_achievement.mjs +30 -1
  341. package/tests/goal_achievement_reward.mjs +205 -0
  342. package/tests/goal_sort_staleness.mjs +2 -1
  343. package/tests/goal_tier.mjs +11 -1
  344. package/tests/google_fonts_nonblocking.mjs +84 -0
  345. package/tests/google_sign_in.mjs +401 -0
  346. package/tests/government_board_expiry.mjs +94 -0
  347. package/tests/government_protected_surfaces.mjs +1 -1
  348. package/tests/government_routes.mjs +1 -1
  349. package/tests/hall_ai_studio.mjs +347 -0
  350. package/tests/hall_page_gate_map.mjs +3 -0
  351. package/tests/hall_studio_home.mjs +61 -2
  352. package/tests/home_attention_cards.mjs +1 -1
  353. package/tests/human_signal.mjs +141 -0
  354. package/tests/install_script_reader.mjs +51 -0
  355. package/tests/integration_fixture_ids.mjs +82 -0
  356. package/tests/invite_expiry_db.mjs +13 -1
  357. package/tests/lib_sh_resolution.mjs +0 -4
  358. package/tests/market_watch_data.mjs +244 -0
  359. package/tests/market_watch_data_db.mjs +308 -0
  360. package/tests/market_watch_module.mjs +114 -0
  361. package/tests/market_watch_run.mjs +503 -0
  362. package/tests/metrics.mjs +124 -0
  363. package/tests/migration_namespace.mjs +14 -0
  364. package/tests/module_api.mjs +2 -0
  365. package/tests/module_loader.mjs +1 -1
  366. package/tests/notes.mjs +182 -0
  367. package/tests/own_scope_guard.mjs +5 -0
  368. package/tests/page_tweak_marker_sql.mjs +76 -0
  369. package/tests/page_tweak_no_block.mjs +135 -0
  370. package/tests/page_tweak_notice.mjs +103 -0
  371. package/tests/payout_approvals.mjs +253 -0
  372. package/tests/payout_approvals_db.mjs +106 -0
  373. package/tests/payout_gates.mjs +322 -0
  374. package/tests/payout_gates_db.mjs +113 -0
  375. package/tests/planning_session_skill.mjs +13 -3
  376. package/tests/project_settings_page.mjs +29 -5
  377. package/tests/project_settings_spend_cap_route.mjs +235 -0
  378. package/tests/promotion_sweep.mjs +29 -1
  379. package/tests/provision.mjs +3 -2
  380. package/tests/provisioning_app_status.mjs +18 -0
  381. package/tests/provisioning_credential_preflight.mjs +53 -0
  382. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  383. package/tests/publish_reconciler_overlap.mjs +60 -0
  384. package/tests/rank_change_broadcast_port.mjs +151 -0
  385. package/tests/seed_tasks.mjs +176 -0
  386. package/tests/seed_verify_criteria.mjs +2 -6
  387. package/tests/site_classified_once.mjs +142 -0
  388. package/tests/speciality_db_shape.mjs +23 -0
  389. package/tests/speciality_session_skills.mjs +48 -2
  390. package/tests/strand_lease.mjs +249 -0
  391. package/tests/strand_watch.mjs +10 -0
  392. package/tests/task_criteria_routes.mjs +275 -0
  393. package/tests/task_criteria_unlink_db.mjs +123 -0
  394. package/tests/task_needs.mjs +233 -0
  395. package/tests/task_needs_db.mjs +110 -0
  396. package/tests/unit_runner_serial.mjs +266 -43
  397. package/tests/version_literals.mjs +5 -7
  398. package/tests/welcome_boot.mjs +16 -5
  399. package/tests/welcome_boot_browser.mjs +36 -4
  400. package/docs/recipes/bongos-cli-release.md +0 -66
  401. package/scripts/bongos/backfill-disciplines.js +0 -144
  402. package/scripts/bongos/backfill-session-earnings.js +0 -123
  403. package/scripts/bongos/backfill-session-types.js +0 -87
  404. package/scripts/bongos/build-bongos.js +0 -94
  405. package/scripts/bongos/cleanup-orphans.js +0 -74
  406. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  407. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  408. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  409. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  410. package/scripts/bongos/smoke-batch-release.sh +0 -316
  411. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  412. package/scripts/bongos/smoke-dependencies.sh +0 -143
  413. package/scripts/gds/backfill-disciplines.js +0 -7
  414. package/scripts/gds/backfill-session-earnings.js +0 -7
  415. package/scripts/gds/backfill-session-types.js +0 -7
  416. package/scripts/gds/cleanup-orphans.js +0 -7
  417. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  418. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  419. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  420. package/scripts/gds/smoke-batch-claim.sh +0 -8
  421. package/scripts/gds/smoke-batch-release.sh +0 -8
  422. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  423. package/scripts/gds/smoke-dependencies.sh +0 -8
  424. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -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
+ });
@@ -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
@@ -129,7 +129,7 @@ function heldClock(h, m) {
129
129
  return Held;
130
130
  }
131
131
 
132
- function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY, rank = 'metic', queue = null } = {}) {
132
+ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY, rank = 'metic', queue = null, search = '' } = {}) {
133
133
  const root = augment(makeEl('body', {}));
134
134
  root.innerHTML = body;
135
135
  const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
@@ -166,7 +166,7 @@ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY, ran
166
166
  const windowObj = {
167
167
  __BRANDING__: {}, __MODULES__: { enabled: {} },
168
168
  BongosClient: { createClient: () => client },
169
- location: { host: 'builders.cloudbongos.com', hostname: 'builders.cloudbongos.com', protocol: 'https:', search: '', pathname: '/studio', assign: (u) => { out.navigated = u; } },
169
+ location: { host: 'builders.cloudbongos.com', hostname: 'builders.cloudbongos.com', protocol: 'https:', search, pathname: '/studio', assign: (u) => { out.navigated = u; } },
170
170
  localStorage: { getItem: () => null, setItem() {} },
171
171
  addEventListener() {},
172
172
  scrollTo() {},
@@ -702,6 +702,65 @@ try {
702
702
  assert.match(down.el('st-panel-body').innerHTML, /could not be read just now/);
703
703
  });
704
704
 
705
+ await test('the full-page reader: a doc at ?note=N and the list at ?view=site, in place of the room, never a dialog', async () => {
706
+ const note = {
707
+ id: 31, page_title: 'Projects', page_id: 'site:projects', author_name: 'Ana', status: 'filed', submitted_at: day(1),
708
+ body_md: ['# Map', 'The labels <b>overlap</b>.', '- one', '- two', '![shot](/api/bongos/page-notes/images/4)', '![evil](https://example.com/x.png)'].join('\n'),
709
+ };
710
+ const s = bootStudio({ search: '?note=31', answers: { ...HOME, 'GET /page-notes/31': ok({ note, ideas: [{ id: 7, title: 'Fix the map' }] }) } });
711
+ await s.settle();
712
+ assert.equal(s.el('st-doc').hidden, false);
713
+ assert.ok(s.html.classList.contains('st-reading'), 'the room steps aside');
714
+ assert.equal(s.el('st-doc-title').textContent, 'Projects');
715
+ const b = s.el('st-doc-body').innerHTML;
716
+ assert.match(b, /Ana · /);
717
+ assert.match(b, /<h3>Map<\/h3>/);
718
+ assert.match(b, /<li>one<\/li><li>two<\/li>/);
719
+ assert.match(b, /The labels &lt;b&gt;overlap&lt;\/b&gt;/, 'the doc is escaped');
720
+ assert.match(b, /<img class="st-doc__img" src="\/api\/bongos\/page-notes\/images\/4"/, 'its own pictures are drawn');
721
+ assert.doesNotMatch(b, /<img[^>]*example\.com/, 'any other picture stays text');
722
+ assert.match(b, /It became 1 idea/);
723
+ assert.match(b, /href="\/#\/idea\/7"/);
724
+
725
+ const gone = bootStudio({ search: '?note=99', answers: HOME });
726
+ await gone.settle();
727
+ assert.match(gone.el('st-doc-body').innerHTML, /could not be opened/);
728
+
729
+ const rows = [{ id: 31, page_id: 'site:projects', page_path: '/projects', page_title: 'Projects', status: 'filed', idea_count: 2, submitted_at: day(1), author_name: 'Ana', excerpt: 'Map labels overlap.', link: '/builders/studio?note=31' }];
730
+ const list = bootStudio({ search: '?view=site', answers: { ...HOME, 'GET /page-notes/site': ok({ notes: rows }) } });
731
+ await list.settle();
732
+ assert.equal(list.el('st-doc-title').textContent, 'From the live site');
733
+ assert.match(list.el('st-doc-body').innerHTML, /st-doc-list__row" href="\/builders\/studio\?note=31"/);
734
+
735
+ const plain = bootStudio({ answers: HOME });
736
+ await plain.settle();
737
+ assert.equal(plain.el('st-doc').hidden, true, 'no link, no reader');
738
+ assert.ok(!plain.html.classList.contains('st-reading'));
739
+ });
740
+
741
+ await test('the overview: the two summary rows sit in the quiet Overview, the three things to do in the actions', () => {
742
+ const nc = HTML.replace(/<!--[\s\S]*?-->/g, '');
743
+ const actions = /<div class="st-actions">([\s\S]*?)\n <\/div>/.exec(nc)[1];
744
+ const glance = /<div class="st-glance"[^>]*>([\s\S]*?)\n <\/div>/.exec(nc)[1];
745
+ for (const id of ['st-resume', 'st-flow', 'studio-approvals']) assert.match(actions, new RegExp(`id="${id}"`), `${id} is an action`);
746
+ for (const id of ['st-status', 'st-site']) {
747
+ assert.match(glance, new RegExp(`id="${id}"`), `${id} is an overview row`);
748
+ assert.doesNotMatch(actions, new RegExp(`id="${id}"`), `${id} is not among the actions`);
749
+ }
750
+ const css = fs.readFileSync(new URL('../modules/hall-ui/public/studio.css', import.meta.url), 'utf8');
751
+ assert.match(css, /\.st-resume::after,\s*\.st-action::after \{/, 'Resume and the doors share the arrow');
752
+ });
753
+
754
+ await test('the reader stands in for the side sheet: craft-mode keeps it shut on the Studio, and a signed-out link says why', async () => {
755
+ const craft = fs.readFileSync(new URL('../modules/hall-ui/public/craft-mode.js', import.meta.url), 'utf8');
756
+ assert.match(craft, /getAttribute\('data-page'\) === 'studio'/);
757
+ assert.match(HTML, /<body[^>]*data-page="studio"/, 'the marker craft-mode reads is on the page');
758
+ const out = bootStudio({ search: '?note=31', signedIn: false, answers: {} });
759
+ await out.settle();
760
+ assert.match(out.el('st-doc-body').innerHTML, /Sign in to read page reviews/);
761
+ assert.equal(out.requests.some((r) => r.url.includes('/page-notes/')), false, 'nothing is asked for while signed out');
762
+ });
763
+
705
764
  await test('the markup: one h1 (the greeting), the tally and four doors, the libs before the page, the full-screen switch', () => {
706
765
  const nc = HTML.replace(/<!--[\s\S]*?-->/g, '');
707
766
  assert.equal((nc.match(/<h1\b/g) || []).length, 1);
@@ -35,7 +35,7 @@ const STUBS = `
35
35
  return d ? { ok: true, status: 200, data: d } : { ok: false, status: 404, data: null };
36
36
  } }) };
37
37
  window.OTBKit = { esc: (s) => String(s), fmtAbsDate: () => 'Oct 1' };
38
- window.OTBBlockersLib = { blockerFacts: (b) => ({ titleHtml: '<a class="ginbox__goal">' + b.title + '</a>', factsHtml: '', noteHtml: '' }) };
38
+ window.OTBBlockersLib = { blockerFacts: (b) => ({ titleHtml: '<a class="ginbox__goal">' + b.title + '</a>', factsHtml: '', noteHtml: '' }), humanKind: () => 'task' };
39
39
  window.OTB = {
40
40
  escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
41
41
  toast() {}, rankLabel: (r) => r,
@@ -0,0 +1,141 @@
1
+ // tests/human_signal.mjs — HUMAN TASK / HUMAN DECISION, the one human signal
2
+ // (task 1004922, owner 2026-10-28).
3
+ //
4
+ // Whatever waits on a person says so with a person, the same two words in the
5
+ // hall (a badge drawn by hall-ask.js) and in every AI chat (a marker line the
6
+ // Conductor teaches Claude and AGENTS.md/GEMINI.md teach every other model).
7
+ // The two halves must say the same words, so this file holds them together.
8
+ //
9
+ // Run: node tests/human_signal.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const ask = require('../modules/hall-ui/public/hall-ask.js');
20
+ const conductor = require('../.claude/hooks/conductor.js');
21
+ const blockersLib = require('../modules/hall-ui/public/blockers-lib.js');
22
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ function t(name, fn) {
27
+ try { fn(); console.log(` PASS ${name}`); passed += 1; }
28
+ catch (e) { console.log(` FAIL ${name}\n ${e.message}`); failed += 1; }
29
+ }
30
+
31
+ const TASK = '🙋 **HUMAN TASK:**';
32
+ const DECISION = '🔀 **HUMAN DECISION:**';
33
+
34
+ t('an ask card is a HUMAN DECISION unless told otherwise', () => {
35
+ const h = ask.html({ q: 'Let Ana join?', where: 'A goal', opts: [{ label: 'Let them in' }] });
36
+ assert.match(h, /hhuman--decision/);
37
+ assert.match(h, />HUMAN DECISION</);
38
+ assert.doesNotMatch(h, /HUMAN TASK/);
39
+ assert.match(h, /A goal/, 'the where line still reads beside the badge');
40
+ });
41
+
42
+ t('a card for something a person must DO is a HUMAN TASK', () => {
43
+ const h = ask.html({ q: 'Blocker', human: 'task', opts: [{ label: 'Resolve it' }] });
44
+ assert.match(h, /hhuman--task/);
45
+ assert.match(h, />HUMAN TASK</);
46
+ assert.doesNotMatch(h, /HUMAN DECISION/);
47
+ });
48
+
49
+ t('a card with no where line carries the badge on its own line, not as an eyebrow', () => {
50
+ const h = ask.html({ q: 'Blocker', human: 'task' });
51
+ assert.match(h, /class="hask__human"><span class="hhuman hhuman--task"/);
52
+ assert.doesNotMatch(h, /hask__where/, 'the Blockers page dropped its repeated eyebrow (task 1004824)');
53
+ });
54
+
55
+ t('human:false drops the badge and keeps the old dot', () => {
56
+ const h = ask.html({ q: 'x', where: 'w', human: false });
57
+ assert.doesNotMatch(h, /hhuman/);
58
+ assert.match(h, /hask__dot/);
59
+ });
60
+
61
+ t('an unknown kind draws nothing rather than a wrong signal', () => {
62
+ assert.equal(ask.human('maybe'), '');
63
+ assert.equal(ask.human(undefined), '');
64
+ });
65
+
66
+ t('the badge is a drawn person, never an emoji (voice V4)', () => {
67
+ for (const k of ['task', 'decision']) {
68
+ const b = ask.human(k);
69
+ assert.match(b, /<svg[^>]*aria-hidden="true"/);
70
+ assert.doesNotMatch(b, /\p{Extended_Pictographic}/u, `${k} badge carries an emoji`);
71
+ }
72
+ });
73
+
74
+ t('an Awaiting UAT row is a HUMAN TASK', () => {
75
+ assert.match(read('modules/hall-ui/public/criterion-uat-ui.js'), /awaiting_uat' && ask \? ask\.human\('task'\)/);
76
+ });
77
+
78
+ // task 1004946: a blocker's own stored ask says whether it is a task or a choice.
79
+ // The six asks open on the live hub on 2026-10-28, cut to their option keys.
80
+ const opts = (...keys) => ({ ask: { options: keys.map((key) => ({ key, label: key })) } });
81
+ const LIVE = [
82
+ ['1000193 V2 or V3 or hold', opts('v2', 'v3', 'hold'), 'decision'],
83
+ ['1000194 bucket or copy or leave', opts('bucket', 'copy', 'none'), 'decision'],
84
+ ['1000195 set it up / not yet', opts('yes', 'later'), 'task'],
85
+ ['1000196 do it / not yet', opts('do', 'later'), 'task'],
86
+ ['1000197 restart / not yet', opts('yes', 'later'), 'task'],
87
+ ['1000198 scope or split', opts('scope', 'split'), 'decision'],
88
+ ];
89
+
90
+ t('a blocker with two or more real options is a HUMAN DECISION; do-it-or-not-yet is a HUMAN TASK', () => {
91
+ for (const [name, b, want] of LIVE) assert.equal(blockersLib.humanKind(b), want, name);
92
+ });
93
+
94
+ t('a blocker with no ask, or only deferrals, is a HUMAN TASK', () => {
95
+ assert.equal(blockersLib.humanKind({}), 'task');
96
+ assert.equal(blockersLib.humanKind({ ask: null }), 'task');
97
+ assert.equal(blockersLib.humanKind(null), 'task');
98
+ assert.equal(blockersLib.humanKind(opts('do')), 'task');
99
+ assert.equal(blockersLib.humanKind(opts('later', 'not_yet', 'wait')), 'task');
100
+ });
101
+
102
+ t('the Blockers page and Home both ask the one rule, never their own', () => {
103
+ assert.match(read('modules/hall-ui/public/blockers.js'), /human: lib\.humanKind\(b\)/);
104
+ assert.match(read('modules/hall-ui/public/goal-inbox.js'), /human: lib\.humanKind\(b\),\s*\n\s*where: 'Waiting on the project owner'/);
105
+ });
106
+
107
+ t('the rendered blocker card wears the badge the rule picks', () => {
108
+ for (const [name, b, want] of LIVE) {
109
+ const h = ask.html({ q: name, human: blockersLib.humanKind(b) });
110
+ assert.match(h, new RegExp(`hhuman--${want}`), name);
111
+ }
112
+ });
113
+
114
+ t('the Conductor teaches both markers on a session\'s first prompt, and only then', () => {
115
+ const first = conductor.evaluateTriggers({ firstPrompt: true }).find((a) => a.id === 'human-signal');
116
+ assert.ok(first, 'no human-signal advisory on the first prompt');
117
+ assert.ok(first.text.includes(TASK) && first.text.includes(DECISION), first.text);
118
+ assert.ok(!conductor.evaluateTriggers({ firstPrompt: false }).some((a) => a.id === 'human-signal'));
119
+ });
120
+
121
+ t('the one-time human line does not count toward the block fingerprint, so prompt two is still a repeat', () => {
122
+ const first = conductor.evaluateTriggers({ firstPrompt: true, interaction: { inject: true, contract: 'P' } });
123
+ const second = conductor.evaluateTriggers({ firstPrompt: false, interaction: { inject: true, contract: 'P' } });
124
+ assert.equal(conductor.fingerprint(first), conductor.fingerprint(second));
125
+ });
126
+
127
+ t('an ask is a decision, so the ask-shape reminder wears the decision mark', () => {
128
+ const a = conductor.evaluateTriggers({ prompt: 'file a blocker' }).find((x) => x.id === 'ask-shape');
129
+ assert.equal(a.icon, '🔀');
130
+ });
131
+
132
+ t('every other model reads the same two markers', () => {
133
+ for (const f of ['AGENTS.md', 'GEMINI.md', 'docs/voice.md']) {
134
+ const s = read(f);
135
+ assert.ok(s.includes(TASK) || s.includes('`🙋 **HUMAN TASK:**`'), `${f} lacks the task marker`);
136
+ assert.ok(s.includes(DECISION), `${f} lacks the decision marker`);
137
+ }
138
+ });
139
+
140
+ console.log(`\n${passed} passed, ${failed} failed`);
141
+ process.exitCode = failed ? 1 : 0;
@@ -0,0 +1,51 @@
1
+ // tests/install_script_reader.mjs
2
+ //
3
+ // task 1004924 — the one-line installers (/downloads/bongos/install.sh and
4
+ // install.ps1) were read from disk and re-substituted on EVERY request, a sync
5
+ // read blocking the event loop. src/bongos-downloads.js installScriptReader reads
6
+ // and substitutes each once per process; serve-internal mounts the routes on it.
7
+ //
8
+ // Its own file, not tests/bongos_downloads.mjs: that file needs the host-only
9
+ // config/bongos-cli.json and exits as HOST-TOPOLOGY-SKIP on the neutral core, so
10
+ // a case added there never runs here or in core CI.
11
+ //
12
+ // Run: node tests/install_script_reader.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import { readFileSync } from 'node:fs';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { installScriptReader } = require('../src/bongos-downloads.js');
21
+ const { test, summary } = makeRunner();
22
+
23
+ await test('substitutes the pinned origin, and reads each script from disk once', () => {
24
+ const reads = [];
25
+ const read = installScriptReader({
26
+ readFile: (name) => { reads.push(name); return `BASE=__GDS_API_BASE__\nAGAIN=__GDS_API_BASE__/x\n# ${name}`; },
27
+ origin: 'https://hub.example',
28
+ });
29
+ for (let i = 0; i < 25; i++) {
30
+ assert.equal(read('bongos-install.sh'), 'BASE=https://hub.example\nAGAIN=https://hub.example/x\n# bongos-install.sh');
31
+ assert.match(read('bongos-install.ps1'), /^BASE=https:\/\/hub\.example\n/);
32
+ }
33
+ assert.deepEqual(reads, ['bongos-install.sh', 'bongos-install.ps1'], 'one disk read per script, not one per request');
34
+ });
35
+
36
+ await test('a missing script is null (the route 404s), and is not re-read either', () => {
37
+ let n = 0;
38
+ const read = installScriptReader({ readFile: () => { n++; throw Object.assign(new Error('nope'), { code: 'ENOENT' }); }, origin: 'https://o' });
39
+ assert.equal(read('bongos-install.sh'), null);
40
+ assert.equal(read('bongos-install.sh'), null);
41
+ assert.equal(n, 1);
42
+ });
43
+
44
+ await test('serve-internal mounts the installer routes on the memoised reader', () => {
45
+ const src = readFileSync(new URL('../src/bongos/serve-internal.js', import.meta.url), 'utf8');
46
+ assert.match(src, /const readBongosInstallScript = installScriptReader\(\{/);
47
+ assert.doesNotMatch(src, /\.split\('__GDS_API_BASE__'\)/, 'no per-request read + split left in the route file');
48
+ assert.equal((src.match(/readBongosInstallScript\('bongos-install\.(sh|ps1)'\)/g) || []).length, 2, 'both installer routes use it');
49
+ });
50
+
51
+ summary();