@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
@@ -0,0 +1,244 @@
1
+ // tests/market_watch_data.mjs — the Market Watch data layer without a database (task
2
+ // 1004936, goal 1000137): the seed built from the committed research files, the hub-only
3
+ // and idempotent seed write, the pure derivations behind levels, streaks, sparklines and
4
+ // the settings gauge, the read queries' project scoping, and the migration's own rules.
5
+ //
6
+ // The SQL itself runs against a real Postgres in tests/market_watch_data_db.mjs (the
7
+ // INTEGRATION set); this file is the DB-free half and runs in the unit lane.
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const seed = require('../modules/market-watch/seed.js');
15
+ const db = require('../modules/market-watch/db.js');
16
+ const hubSeed = require('../modules/market-watch/pollers/hub-seed.js');
17
+ const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
18
+
19
+ const market = seed.readSeed('market-data.json');
20
+ const partners = seed.readSeed('partners-data.json');
21
+ const built = seed.buildSeed({ market, partners });
22
+
23
+ // A pool that records every statement and answers each SELECT … id with one row.
24
+ function fakePool({ failOn = null } = {}) {
25
+ const calls = [];
26
+ let id = 0;
27
+ const client = {
28
+ async query(sql, params = []) {
29
+ calls.push({ sql, params });
30
+ if (failOn && failOn.test(sql)) throw new Error('boom');
31
+ if (/^\s*SELECT id FROM/.test(sql)) return { rows: [{ id: ++id }], rowCount: 1 };
32
+ return { rows: [], rowCount: 1 };
33
+ },
34
+ release() { calls.push({ sql: 'RELEASE' }); },
35
+ };
36
+ return { calls, pool: { connect: async () => client, query: client.query } };
37
+ }
38
+
39
+ test('the seed carries all 36 researched companies and all 24 partners, scored and sourced', () => {
40
+ assert.equal(market.length, 36);
41
+ assert.equal(partners.length, 24);
42
+ const comps = built.items.filter((i) => i.kind === 'competitor');
43
+ const parts = built.items.filter((i) => i.kind === 'partner');
44
+ assert.equal(comps.length, 36);
45
+ assert.equal(parts.length, 24);
46
+ for (const it of built.items) {
47
+ const rubricKeys = seed.RUBRIC[it.kind].map((d) => d.key);
48
+ assert.deepEqual(Object.keys(it.scores).sort(), [...rubricKeys, 'overall'].sort(), `${it.name}: five rubric dimensions plus overall`);
49
+ for (const v of Object.values(it.scores)) assert.ok(v >= 0 && v <= 10, `${it.name}: ${v}`);
50
+ assert.ok(it.entries.length >= 1, `${it.name} has a case file`);
51
+ for (const e of it.entries) {
52
+ assert.match(e.sourceUrl, /^https?:\/\//, `${it.name}: every entry has a source`);
53
+ assert.ok([1, 2, 3].includes(e.confidence));
54
+ assert.ok(['insight', 'news', 'datapoint'].includes(e.type));
55
+ }
56
+ }
57
+ // The item key the migration enforces is unique across the seed, so no row is lost to it.
58
+ assert.equal(new Set(built.items.map((i) => `${i.kind}|${i.url}|${i.name}`)).size, 60);
59
+ // An unverified claim is never seeded as better than low confidence.
60
+ const unverified = built.items.flatMap((i) => i.entries).filter((e) => /unverified/i.test(e.takeaway));
61
+ assert.ok(unverified.length > 0);
62
+ for (const e of unverified) assert.equal(e.confidence, 1, e.takeaway);
63
+ });
64
+
65
+ test('the rules seed as version 1 with both rubrics, defined', () => {
66
+ assert.equal(built.rules.version, 1);
67
+ for (const kind of ['competitor', 'partner']) {
68
+ assert.equal(built.rules.rubric[kind].length, 5);
69
+ for (const d of built.rules.rubric[kind]) assert.ok(d.label && d.definition && d.weight > 0, JSON.stringify(d));
70
+ }
71
+ assert.ok(built.rules.searchQueries.length > 0 && built.rules.sources.length > 0 && built.rules.instructions.length > 0);
72
+ });
73
+
74
+ test('projectKey is the public origin, normalised, and refuses anything else', () => {
75
+ assert.equal(seed.projectKey('https://CloudBongos.com/'), 'https://cloudbongos.com');
76
+ assert.equal(seed.projectKey('http://localhost:3000'), 'http://localhost:3000');
77
+ for (const bad of ['', null, 'cloudbongos.com', 'https://x.com/path', 'ftp://x.com']) assert.equal(seed.projectKey(bad), null, String(bad));
78
+ });
79
+
80
+ test('the seed writes nothing off the hub, or with no origin', async () => {
81
+ const { pool, calls } = fakePool();
82
+ assert.deepEqual(await seed.seedHub({ pool, isHub: false, project: 'https://hub.example' }), { seeded: false, skipped: 'not_hub' });
83
+ assert.deepEqual(await seed.seedHub({ pool, isHub: 'true', project: 'https://hub.example' }), { seeded: false, skipped: 'not_hub' });
84
+ assert.deepEqual(await seed.seedHub({ pool, isHub: true, project: 'not an origin' }), { seeded: false, skipped: 'no_project_origin' });
85
+ assert.equal(calls.length, 0, 'not one statement');
86
+ });
87
+
88
+ test('on the hub it writes in one transaction, every insert idempotent and scoped to the project', async () => {
89
+ const { pool, calls } = fakePool();
90
+ const r = await seed.seedHub({ pool, isHub: true, project: 'https://Hub.example/' });
91
+ assert.equal(r.seeded, true);
92
+ assert.equal(r.project, 'https://hub.example');
93
+ assert.equal(calls[0].sql, 'BEGIN');
94
+ assert.equal(calls.at(-2).sql, 'COMMIT');
95
+ assert.equal(calls.at(-1).sql, 'RELEASE');
96
+ const inserts = calls.filter((c) => /^\s*INSERT/.test(c.sql));
97
+ assert.equal(inserts.length, 1 + 1 + 60 + 360 + built.items.reduce((a, i) => a + i.entries.length, 0));
98
+ for (const c of inserts) assert.match(c.sql, /ON CONFLICT .* DO NOTHING/s, c.sql);
99
+ for (const c of calls.filter((x) => /market_watch_(items|runs|rule_versions)/.test(x.sql) && x.params)) {
100
+ assert.equal(c.params[0], 'https://hub.example', 'project is $1 on every project-owned row');
101
+ }
102
+ // No person is invented: no seed statement writes an author, approver or adder.
103
+ for (const c of inserts) assert.doesNotMatch(c.sql, /author_id|approved_by|added_by|proposed_by/);
104
+ });
105
+
106
+ test('a failed write rolls back and releases', async () => {
107
+ const { pool, calls } = fakePool({ failOn: /INSERT INTO market_watch_score_snapshots/ });
108
+ await assert.rejects(seed.seedHub({ pool, isHub: true, project: 'https://hub.example' }), /boom/);
109
+ assert.ok(calls.some((c) => c.sql === 'ROLLBACK'));
110
+ assert.ok(!calls.some((c) => c.sql === 'COMMIT'));
111
+ assert.equal(calls.at(-1).sql, 'RELEASE');
112
+ });
113
+
114
+ test('the boot poller never throws into the server and counts a failure', async () => {
115
+ let reported = 0;
116
+ const r = await hubSeed.runOnce({ seedHub: async () => { throw new Error('db down'); }, reportError: () => { reported += 1; } });
117
+ assert.equal(r.seeded, false);
118
+ assert.equal(reported, 1);
119
+ const ok = await hubSeed.runOnce({ seedHub: async () => ({ seeded: false, skipped: 'not_hub' }) });
120
+ assert.equal(ok.skipped, 'not_hub');
121
+ });
122
+
123
+ // ---- the derivations ------------------------------------------------------
124
+
125
+ const RUNS = [
126
+ { id: 4, status: 'done', created_at: '2026-11-11T00:00:00Z' },
127
+ { id: 1, status: 'done', created_at: '2026-10-28T00:00:00Z' },
128
+ { id: 3, status: 'skipped', created_at: '2026-11-07T00:00:00Z' },
129
+ { id: 2, status: 'partial', created_at: '2026-11-04T00:00:00Z' },
130
+ ];
131
+
132
+ test('runs are numbered oldest first, and a run that never ran takes no number', () => {
133
+ const n = db.numberRuns(RUNS);
134
+ assert.deepEqual(n.map((r) => [r.id, r.n]), [[1, 1], [2, 2], [3, null], [4, 3]]);
135
+ });
136
+
137
+ test('level counts the runs that found something; streak counts the latest ones in a row', () => {
138
+ const n = db.numberRuns(RUNS);
139
+ assert.deepEqual(db.progress(n, []), { level: 1, streak: 0 }, 'a tracked item is level 1');
140
+ assert.deepEqual(db.progress(n, [1, 2, 4]), { level: 3, streak: 3 }, 'a skipped run does not break a streak');
141
+ assert.deepEqual(db.progress(n, [1, 4]), { level: 2, streak: 1 });
142
+ assert.deepEqual(db.progress(n, [1, 2]), { level: 2, streak: 0 }, 'nothing in the latest run');
143
+ assert.deepEqual(db.progress(n, ['4']), { level: 1, streak: 1 }, 'ids compare as strings (pg bigint)');
144
+ });
145
+
146
+ test('sparklines: one point per run that scored the item, null where a dimension was not scored', () => {
147
+ const n = db.numberRuns(RUNS);
148
+ const s = db.sparklines([
149
+ { run_id: 1, dimension: 'overall', value: '5.0' }, { run_id: 1, dimension: 'human_gates', value: '6' },
150
+ { run_id: 4, dimension: 'overall', value: '6.0' },
151
+ { run_id: 99, dimension: 'overall', value: '9' },
152
+ ], n);
153
+ assert.deepEqual(s.runs.map((r) => r.n), [1, 3]);
154
+ assert.deepEqual(s.series, { overall: [5, 6], human_gates: [6, null] });
155
+ });
156
+
157
+ test('the ask streak counts answered weeks back from the newest; an open unanswered week does not break it', () => {
158
+ assert.equal(db.weekStreak([{ id: 'w4', open: true }, { id: 'w3' }, { id: 'w2' }, { id: 'w1' }], ['w3', 'w2']), 2);
159
+ assert.equal(db.weekStreak([{ id: 'w4', open: false }, { id: 'w3' }], ['w3']), 0, 'a closed missed week breaks it');
160
+ assert.equal(db.weekStreak([{ id: 'w4', open: true }, { id: 'w3' }], ['w4', 'w3']), 2);
161
+ assert.equal(db.weekStreak([], []), 0);
162
+ });
163
+
164
+ test('the next run is due one cadence after the last; no run yet means due now', () => {
165
+ assert.equal(db.nextRunDue(null, 'weekly'), null);
166
+ assert.equal(db.nextRunDue('2026-11-01T09:00:00Z', 'weekly').toISOString(), '2026-11-08T09:00:00.000Z');
167
+ assert.equal(db.nextRunDue('2026-11-01T09:00:00Z', 'biweekly').toISOString(), '2026-11-15T09:00:00.000Z');
168
+ assert.throws(() => db.nextRunDue('2026-11-01T09:00:00Z', 'daily'), /unknown cadence/);
169
+ const [a, b] = db.monthBounds(new Date('2026-12-31T23:59:00Z'));
170
+ assert.equal(a.toISOString(), '2026-12-01T00:00:00.000Z');
171
+ assert.equal(b.toISOString(), '2027-01-01T00:00:00.000Z');
172
+ });
173
+
174
+ // ---- the reads are project-scoped ----------------------------------------
175
+
176
+ test('every read refuses a missing project and passes it as $1', async () => {
177
+ const seen = [];
178
+ const pool = {
179
+ async query(sql, params) {
180
+ seen.push({ sql, params });
181
+ if (/sum\(ri\.cost_usd\), 0\) AS spent/.test(sql)) return { rows: [{ spent: '0' }] };
182
+ return { rows: [] };
183
+ },
184
+ };
185
+ const P = 'https://hub.example';
186
+ const reads = [
187
+ () => db.listItems(pool, { project: P }),
188
+ () => db.getItem(pool, { project: P, id: 1 }),
189
+ () => db.caseFile(pool, { project: P, itemId: 1 }),
190
+ () => db.scoreHistory(pool, { project: P, itemId: 1 }),
191
+ () => db.rules(pool, { project: P }),
192
+ () => db.proposals(pool, { project: P }),
193
+ () => db.weeklyAsk(pool, { project: P, weekOf: '2026-11-02' }),
194
+ () => db.askStreak(pool, { project: P, builderId: 1 }),
195
+ () => db.settingsView(pool, { project: P }),
196
+ () => db.remainingItems(pool, { project: P, runId: 1 }),
197
+ ];
198
+ for (const r of reads) await r();
199
+ assert.ok(seen.length >= reads.length);
200
+ for (const s of seen) {
201
+ assert.equal(s.params[0], P, s.sql);
202
+ assert.match(s.sql, /project = \$1/, s.sql);
203
+ }
204
+ for (const fn of ['listItems', 'caseFile', 'scoreHistory', 'rules', 'proposals', 'weeklyAsk', 'settingsView', 'remainingItems']) {
205
+ await assert.rejects(db[fn](pool, {}), /project key is required/, fn);
206
+ }
207
+ const v = await db.settingsView(pool, { project: P });
208
+ assert.deepEqual(v.settings, { ...db.DEFAULT_SETTINGS }, 'an unconfigured project reads the watch as off');
209
+ assert.equal(v.nextRunDue, null);
210
+ });
211
+
212
+ // ---- the migration's own rules -------------------------------------------
213
+
214
+ test('the migration is additive, namespaced, idempotent, transactional and ASCII', () => {
215
+ assert.match(MIGRATION, /^\s*BEGIN;/m);
216
+ assert.match(MIGRATION, /^\s*COMMIT;/m);
217
+ const code = MIGRATION.replace(/--[^\n]*/g, ''); // the prose may say "never a DELETE"
218
+ assert.doesNotMatch(code, /\bDROP\b|\bTRUNCATE\b|\bDELETE\b/i);
219
+ assert.ok(/^[\x00-\x7F]*$/.test(MIGRATION), 'ASCII only');
220
+ const tables = [...MIGRATION.matchAll(/CREATE TABLE (IF NOT EXISTS )?(\w+)/g)];
221
+ assert.equal(tables.length, 10);
222
+ for (const t of tables) {
223
+ assert.ok(t[1], `${t[2]} is IF NOT EXISTS`);
224
+ assert.match(t[2], /^market_watch_/);
225
+ }
226
+ for (const i of MIGRATION.matchAll(/CREATE (UNIQUE )?INDEX (IF NOT EXISTS )?(\w+)/g)) assert.ok(i[2], `${i[3]} is IF NOT EXISTS`);
227
+ });
228
+
229
+ test('every seed ON CONFLICT target is a unique key the migration declares', () => {
230
+ const src = readFileSync(new URL('../modules/market-watch/seed.js', import.meta.url), 'utf8');
231
+ const want = {
232
+ '(project, version)': /UNIQUE \(project, version\)/,
233
+ '(project) WHERE trigger = \'seed\'': /market_watch_runs_one_seed\s+ON market_watch_runs \(project\) WHERE trigger = 'seed'/,
234
+ '(project, kind, url, name)': /UNIQUE \(project, kind, url, name\)/,
235
+ '(item_id, run_id, dimension)': /UNIQUE \(item_id, run_id, dimension\)/,
236
+ '(item_id, run_id, md5(takeaway))': /market_watch_log_entries_once\s+ON market_watch_log_entries \(item_id, run_id, md5\(takeaway\)\)/,
237
+ };
238
+ const targets = [...src.matchAll(/ON CONFLICT (.+?) DO NOTHING/g)].map((m) => m[1]);
239
+ assert.equal(targets.length, Object.keys(want).length);
240
+ for (const t of targets) {
241
+ assert.ok(want[t], `unexpected conflict target ${t}`);
242
+ assert.match(MIGRATION, want[t], t);
243
+ }
244
+ });
@@ -0,0 +1,308 @@
1
+ // tests/market_watch_data_db.mjs — the Market Watch tables, seed and read queries against a
2
+ // REAL Postgres (task 1004936, goal 1000137).
3
+ //
4
+ // What only a real engine can say: the migration applies, and applies again as a no-op;
5
+ // the hub seed loads 60 items and loads again inserting nothing; the CHECKs and partial
6
+ // unique indexes refuse the rows they exist to refuse (an unsourced entry, a done item with
7
+ // no cost, a skipped run with no reason, a second active rules version, a second seed run);
8
+ // and the read queries return the levels, streaks, run numbers, sparklines, spend and
9
+ // resume set the screens and the weekly run need.
10
+ //
11
+ // The module is default:false, so CI's migrate.sh does not apply its migrations; this file
12
+ // applies market_watch_001 itself (twice). Its rows live under a project origin no real
13
+ // instance has and are deleted before and after. Real-DB integration test: self-skips
14
+ // without a reachable Postgres; in the INTEGRATION set (scripts/bongos/run-unit-tests.js)
15
+ // so the DB-free lane never runs it. The DB-free half is tests/market_watch_data.mjs.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/market_watch_data_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { strict as assert } from 'node:assert';
22
+ import { insertTestBuilders } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const seed = require('../modules/market-watch/seed.js');
27
+ const db = require('../modules/market-watch/db.js');
28
+
29
+ const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
30
+ const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
31
+ const run = require('../modules/market-watch/run.js');
32
+ const store = require('../modules/market-watch/store.js');
33
+
34
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
35
+ try {
36
+ await pool.query('SELECT 1');
37
+ } catch (e) {
38
+ console.log(`market_watch_data_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
39
+ await pool.end().catch(() => {});
40
+ process.exit(0);
41
+ }
42
+
43
+ const P = 'https://t1004936-hub.example';
44
+ const OTHER = 'https://t1004936-other.example';
45
+ const ALICE = 91004936, BOB = 91004937;
46
+ const q = (sql, params) => pool.query(sql, params);
47
+
48
+ async function cleanup() {
49
+ for (const project of [P, OTHER]) {
50
+ await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [project]).catch(() => {});
51
+ await q(`DELETE FROM market_watch_ask_answers WHERE ask_id IN (SELECT id FROM market_watch_weekly_asks WHERE project = $1)`, [project]).catch(() => {});
52
+ await q(`DELETE FROM market_watch_weekly_asks WHERE project = $1`, [project]).catch(() => {});
53
+ await q(`DELETE FROM market_watch_score_snapshots WHERE item_id IN (SELECT id FROM market_watch_items WHERE project = $1)`, [project]).catch(() => {});
54
+ await q(`DELETE FROM market_watch_log_entries WHERE item_id IN (SELECT id FROM market_watch_items WHERE project = $1)`, [project]).catch(() => {});
55
+ await q(`DELETE FROM market_watch_run_items WHERE run_id IN (SELECT id FROM market_watch_runs WHERE project = $1)`, [project]).catch(() => {});
56
+ await q(`DELETE FROM market_watch_runs WHERE project = $1`, [project]).catch(() => {});
57
+ await q(`DELETE FROM market_watch_items WHERE project = $1`, [project]).catch(() => {});
58
+ await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [project]).catch(() => {});
59
+ await q(`DELETE FROM market_watch_settings WHERE project = $1`, [project]).catch(() => {});
60
+ }
61
+ await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[ALICE, BOB]]).catch(() => {});
62
+ }
63
+
64
+ // A statement that must be refused by the schema. Each runs in its own SAVEPOINT-free
65
+ // autocommit statement, so a refusal leaves nothing behind.
66
+ async function refused(sql, params, why) {
67
+ await assert.rejects(q(sql, params), /violates|invalid input/, why);
68
+ }
69
+
70
+ let failed = 0;
71
+ async function check(name, fn) {
72
+ try { await fn(); console.log(`ok - ${name}`); } catch (e) { failed += 1; console.log(`not ok - ${name}\n ${e && e.stack}`); }
73
+ }
74
+
75
+ try {
76
+ await check('the migration applies, and applies again as a no-op', async () => {
77
+ await q(MIGRATION);
78
+ await q(MIGRATION);
79
+ // market_watch_002 (task 1004937): the run's key columns, one-open-run index and the
80
+ // 'run' proposal origin. A new migration here joins this file (CI never applies it).
81
+ await q(MIGRATION_002);
82
+ await q(MIGRATION_002);
83
+ const { rows } = await q(`SELECT count(*)::int AS n FROM information_schema.tables WHERE table_name LIKE 'market\\_watch\\_%'`);
84
+ assert.ok(rows[0].n >= 10, `tables: ${rows[0].n}`);
85
+ });
86
+ await cleanup();
87
+ await insertTestBuilders(pool, [{ id: ALICE, login: 't1004936-alice' }, { id: BOB, login: 't1004936-bob' }]);
88
+
89
+ await check('the seed loads off the hub never, on the hub once, and a second load inserts nothing', async () => {
90
+ assert.equal((await seed.seedHub({ pool, isHub: false, project: OTHER })).skipped, 'not_hub');
91
+ const first = await seed.seedHub({ pool, isHub: true, project: P });
92
+ assert.equal(first.seeded, true);
93
+ assert.equal(first.inserted.items, 60);
94
+ assert.equal(first.inserted.scores, 360);
95
+ assert.equal(first.inserted.runs, 1);
96
+ assert.equal(first.inserted.rules, 1);
97
+ assert.ok(first.inserted.entries > 60);
98
+ const again = await seed.seedHub({ pool, isHub: true, project: P });
99
+ assert.deepEqual(again.inserted, { rules: 0, runs: 0, items: 0, scores: 0, entries: 0 });
100
+ const { rows: [n] } = await q(`SELECT count(*)::int AS items, count(*) FILTER (WHERE kind = 'competitor')::int AS comps
101
+ FROM market_watch_items WHERE project = $1`, [P]);
102
+ assert.deepEqual(n, { items: 60, comps: 36 });
103
+ assert.equal((await db.listItems(pool, { project: OTHER })).length, 0, 'nothing reached the other project');
104
+ });
105
+
106
+ await check('a removed item stays removed when the seed runs again', async () => {
107
+ await q(`UPDATE market_watch_items SET status = 'removed' WHERE project = $1 AND name = 'Sweep'`, [P]);
108
+ await seed.seedHub({ pool, isHub: true, project: P });
109
+ const items = await db.listItems(pool, { project: P });
110
+ assert.equal(items.length, 59);
111
+ assert.ok(!items.some((i) => i.name === 'Sweep'));
112
+ assert.equal((await db.listItems(pool, { project: P, includeRemoved: true })).length, 60);
113
+ });
114
+
115
+ const { rows: [{ id: seedRun }] } = await q(`SELECT id FROM market_watch_runs WHERE project = $1 AND trigger = 'seed'`, [P]);
116
+ const { rows: [{ id: rule1 }] } = await q(`SELECT id FROM market_watch_rule_versions WHERE project = $1 AND version = 1`, [P]);
117
+ const { rows: [copilot] } = await q(`SELECT id FROM market_watch_items WHERE project = $1 AND name = 'GitHub Copilot coding agent'`, [P]);
118
+
119
+ await check('seeded items read at level 1, streak 1, with their overall score and case file', async () => {
120
+ const items = await db.listItems(pool, { project: P, kind: 'competitor' });
121
+ assert.equal(items.length, 35);
122
+ const c = items.find((i) => i.id === Number(copilot.id));
123
+ assert.equal(c.level, 1);
124
+ assert.equal(c.streak, 1);
125
+ assert.equal(typeof c.overall, 'number');
126
+ const file = await db.caseFile(pool, { project: P, itemId: copilot.id });
127
+ assert.ok(file.length >= 1);
128
+ for (const e of file) { assert.equal(e.run, 1); assert.match(e.source_url, /^https?:\/\//); }
129
+ assert.equal(await db.getItem(pool, { project: OTHER, id: copilot.id }), null, 'another project cannot read it');
130
+ });
131
+
132
+ // A skipped run, then a real one that finds something on Copilot and re-scores it.
133
+ const { rows: [{ id: skipped }] } = await q(
134
+ `INSERT INTO market_watch_runs (project, status, reason, trigger, created_at) VALUES ($1, 'skipped', 'cap_reached', 'poller', '2026-11-04T09:00:00Z') RETURNING id`, [P]);
135
+ const { rows: [{ id: run2 }] } = await q(
136
+ `INSERT INTO market_watch_runs (project, status, trigger, rule_version_id, started_at, finished_at, created_at)
137
+ VALUES ($1, 'done', 'poller', $2, now(), now(), '2026-11-11T09:00:00Z') RETURNING id`, [P, rule1]);
138
+ await q(`INSERT INTO market_watch_run_items (run_id, item_id, status, cost_usd, finished_at) VALUES ($1, $2, 'done', 0.0950, '2026-11-11T09:05:00Z')`, [run2, copilot.id]);
139
+ await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence) VALUES ($1, $2, 'news', 'Agent mode reaches self-hosted Enterprise Server.', 'https://github.blog/x', 3)`, [copilot.id, run2]);
140
+ await q(`INSERT INTO market_watch_score_snapshots (item_id, run_id, dimension, value) VALUES ($1, $2, 'overall', 7), ($1, $2, 'human_gates', 8)`, [copilot.id, run2]);
141
+
142
+ await check('a later run levels the item up, a skipped run takes no number and breaks no streak', async () => {
143
+ const c = await db.getItem(pool, { project: P, id: copilot.id });
144
+ assert.deepEqual([c.level, c.streak, c.overall], [2, 2, 7]);
145
+ const other = (await db.listItems(pool, { project: P })).find((i) => i.name === 'Cursor (Anysphere)');
146
+ assert.deepEqual([other.level, other.streak], [1, 0], 'nothing found on it in the latest run');
147
+ const runs = await db.numberedRuns(pool, { project: P });
148
+ assert.deepEqual(runs.map((r) => [Number(r.id), r.n]), [[Number(seedRun), 1], [Number(skipped), null], [Number(run2), 2]]);
149
+ const file = await db.caseFile(pool, { project: P, itemId: copilot.id });
150
+ assert.equal(file[0].run, 2, 'newest first');
151
+ });
152
+
153
+ await check('the sparklines align on the runs that scored the item', async () => {
154
+ const h = await db.scoreHistory(pool, { project: P, itemId: copilot.id });
155
+ assert.deepEqual(h.runs.map((r) => r.n), [1, 2]);
156
+ assert.equal(h.series.overall[1], 7);
157
+ assert.equal(h.series.human_gates.length, 2);
158
+ assert.equal(h.series.ai_agents_write_code[1], null, 'not scored in run 2: null, never carried forward');
159
+ });
160
+
161
+ await check('a resumed run is offered only the items it has not done', async () => {
162
+ const left = await db.remainingItems(pool, { project: P, runId: run2 });
163
+ assert.equal(left.length, 58, '59 active minus the one done');
164
+ assert.ok(!left.some((i) => i.id === Number(copilot.id)));
165
+ assert.deepEqual(await db.remainingItems(pool, { project: OTHER, runId: run2 }), [], 'another project cannot read the run');
166
+ });
167
+
168
+ await check('settings read the defaults, then the row, with this month spend and the skipped run on the strip', async () => {
169
+ let v = await db.settingsView(pool, { project: P, now: new Date('2026-11-20T00:00:00Z') });
170
+ assert.equal(v.settings.configured, false);
171
+ assert.equal(v.settings.enabled, false);
172
+ assert.equal(v.spentThisMonthUsd, 0.095);
173
+ assert.deepEqual(v.recentRuns.map((r) => [r.status, r.reason]), [['done', null], ['skipped', 'cap_reached']], 'the seed run is not on the strip');
174
+ assert.equal(v.recentRuns[0].items_done, 1);
175
+ assert.equal(v.nextRunDue.toISOString(), '2026-11-18T09:00:00.000Z');
176
+ await q(`INSERT INTO market_watch_settings (project, enabled, monthly_cap_usd, cadence, updated_by) VALUES ($1, true, 25, 'biweekly', $2)`, [P, ALICE]);
177
+ v = await db.settingsView(pool, { project: P, now: new Date('2026-12-02T00:00:00Z') });
178
+ assert.deepEqual([v.settings.enabled, v.settings.monthly_cap_usd, v.settings.cadence], [true, 25, 'biweekly']);
179
+ assert.equal(v.spentThisMonthUsd, 0, 'a new month starts at zero');
180
+ });
181
+
182
+ await check('rules: one active, one proposed next, and the history in order', async () => {
183
+ const { rows: [{ id: v2 }] } = await q(
184
+ `INSERT INTO market_watch_rule_versions (project, version, status, rubric, summary, reason, author_id)
185
+ VALUES ($1, 2, 'proposed', '{}'::jsonb, '+ bounty boards', 'finds Algora and Opire', $2) RETURNING id`, [P, BOB]);
186
+ const r = await db.rules(pool, { project: P });
187
+ assert.deepEqual(r.versions.map((v) => [v.version, v.status]), [[1, 'active'], [2, 'proposed']]);
188
+ assert.equal(r.active.version, 1);
189
+ assert.equal(r.proposed.id, Number(v2));
190
+ assert.equal(r.active.rubric.competitor.length, 5);
191
+ await refused(`INSERT INTO market_watch_rule_versions (project, version, status, reason) VALUES ($1, 3, 'proposed', 'x')`, [P], 'a second proposed version');
192
+ await refused(`INSERT INTO market_watch_rule_versions (project, version, status, reason) VALUES ($1, 3, 'active', 'x')`, [P], 'a second active version');
193
+ await q(`INSERT INTO market_watch_proposals (project, kind, origin, rule_version_id, reason, proposed_by) VALUES ($1, 'rule_change', 'member', $2, 'count bounty boards', $3)`, [P, v2, BOB]);
194
+ });
195
+
196
+ await check('a weekly ask pools taps into tallies and proposals, and the member streak reads', async () => {
197
+ const ins = async (week, closes) => (await q(
198
+ `INSERT INTO market_watch_weekly_asks (project, week_of, question_key, prompt, options, closes_at)
199
+ VALUES ($1, $2, 'rival', 'What counts as a competitor?', '[{"label":"AI IDEs"}]'::jsonb, $3) RETURNING id`, [P, week, closes])).rows[0].id;
200
+ const w1 = await ins('2026-11-02', '2026-11-08T00:00:00Z');
201
+ const w2 = await ins('2026-11-09', '2026-11-15T00:00:00Z');
202
+ const w3 = await ins('2026-11-16', '2099-01-01T00:00:00Z');
203
+ const tap = async (ask, who, choice) => (await q(
204
+ `INSERT INTO market_watch_ask_answers (ask_id, builder_id, choice) VALUES ($1, $2, $3) RETURNING id`, [ask, who, choice])).rows[0].id;
205
+ await tap(w1, ALICE, 'AI IDEs');
206
+ await tap(w2, ALICE, 'AI IDEs');
207
+ const a = await tap(w2, BOB, 'AI IDEs');
208
+ await tap(w2, BOB, 'Bounty boards');
209
+ await refused(`INSERT INTO market_watch_ask_answers (ask_id, builder_id, choice) VALUES ($1, $2, 'AI IDEs')`, [w2, BOB], 'a second tap on the same chip');
210
+ const week = await db.weeklyAsk(pool, { project: P, weekOf: '2026-11-09' });
211
+ assert.deepEqual(week[0].tallies, [{ choice: 'AI IDEs', taps: 2 }, { choice: 'Bounty boards', taps: 1 }]);
212
+ assert.equal(await db.askStreak(pool, { project: P, builderId: ALICE, now: new Date('2026-11-17T00:00:00Z') }), 2, 'w3 is open: not yet a miss');
213
+ assert.equal(await db.askStreak(pool, { project: P, builderId: BOB, now: new Date('2026-11-17T00:00:00Z') }), 1);
214
+ await q(`INSERT INTO market_watch_proposals (project, kind, origin, answer_id, payload, proposed_by) VALUES ($1, 'add_item', 'weekly_ask', $2, '{"name":"Cursor"}'::jsonb, $3)`, [P, a, BOB]);
215
+ await refused(`INSERT INTO market_watch_proposals (project, kind, origin, answer_id, payload) VALUES ($1, 'add_item', 'weekly_ask', $2, '{}'::jsonb)`, [P, a], 'one tap is one proposal');
216
+ const open = await db.proposals(pool, { project: P });
217
+ assert.deepEqual(open.map((p) => p.kind).sort(), ['add_item', 'rule_change']);
218
+ assert.equal(w3 > 0, true);
219
+ });
220
+
221
+ await check('the schema refuses what its rules forbid', async () => {
222
+ await refused(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence) VALUES ($1, $2, 'news', 'no source', '', 2)`, [copilot.id, run2], 'an unsourced entry');
223
+ await refused(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence) VALUES ($1, $2, 'news', 'too sure', 'https://x.example', 4)`, [copilot.id, run2], 'confidence above 3');
224
+ await refused(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence) VALUES ($1, $2, 'news', 'Agent mode reaches self-hosted Enterprise Server.', 'https://github.blog/y', 3)`, [copilot.id, run2], 'the same takeaway twice in one run (a resumed item)');
225
+ await refused(`INSERT INTO market_watch_run_items (run_id, item_id, status) VALUES ($1, $2, 'done')`, [run2, (await q(`SELECT id FROM market_watch_items WHERE project = $1 AND name = 'Factory'`, [P])).rows[0].id], 'done with no cost');
226
+ await refused(`INSERT INTO market_watch_run_items (run_id, item_id, status, cost_usd) VALUES ($1, $2, 'done', 0.1)`, [run2, copilot.id], 'the same item twice in a run');
227
+ await refused(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'skipped', 'poller')`, [P], 'a skipped run with no reason');
228
+ await refused(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'done', 'seed')`, [P], 'a second seed run');
229
+ await refused(`INSERT INTO market_watch_score_snapshots (item_id, run_id, dimension, value) VALUES ($1, $2, 'overall', 11)`, [copilot.id, skipped], 'a score above 10');
230
+ await refused(`INSERT INTO market_watch_proposals (project, kind, origin) VALUES ($1, 'rescore', 'member')`, [P], 'a rescore that names no item');
231
+ await refused(`INSERT INTO market_watch_settings (project, cadence) VALUES ($1, 'daily')`, [OTHER], 'an unknown cadence');
232
+ });
233
+
234
+ // The weekly run (task 1004937) through the REAL store on the seeded project: a fake
235
+ // Messages API client (no network, no key spent) answers every item with two sourced
236
+ // entries, two scores and one candidate.
237
+ await check('the weekly run on seed data writes sourced entries, score snapshots, one proposal, and the cost', async () => {
238
+ const NEWS = 'https://news.example.com/launch';
239
+ const answer = {
240
+ stop_reason: 'end_turn',
241
+ content: [
242
+ { type: 'web_search_tool_result', tool_use_id: 's1', content: [{ type: 'web_search_result', url: NEWS }] },
243
+ { type: 'text', text: JSON.stringify({
244
+ entries: [
245
+ { type: 'news', takeaway: 'Shipped a self-hosted agent.', source_url: NEWS, source_label: 'news', confidence: 'high', dimension: null },
246
+ { type: 'news', takeaway: 'Unsourced.', source_url: 'https://invented.example/x', source_label: '', confidence: 'high', dimension: null },
247
+ ],
248
+ scores: [{ dimension: 'overall', value: 6, reason: 'Moved closer.' }],
249
+ candidates: [{ name: 'Newco Agents', url: 'https://newco.example.com', kind: 'competitor', why: 'Named in the launch.' }],
250
+ }) },
251
+ ],
252
+ usage: { input_tokens: 20000, output_tokens: 1500, server_tool_use: { web_search_requests: 2 } },
253
+ };
254
+ const keys = [];
255
+ const mirrored = [];
256
+ const d = {
257
+ project: P,
258
+ store: store.createStore(pool),
259
+ now: () => Date.parse('2027-01-10T10:00:00Z'), // well past run2, in a month with no spend
260
+ readKey: async () => 'sk-ant-test-not-a-real-key-WXYZ',
261
+ createClient: (k) => { keys.push(k); return { messages: { create: async () => answer } }; },
262
+ economy: { logCost: async (row) => { mirrored.push(row); } },
263
+ warn: () => {},
264
+ };
265
+ const r = await run.runDue(d);
266
+ assert.equal(r.outcome, 'done', JSON.stringify(r));
267
+ assert.deepEqual(keys, ['sk-ant-test-not-a-real-key-WXYZ']);
268
+ const { rows: [runRow] } = await q(`SELECT status, reason, claimed_until FROM market_watch_runs WHERE id = $1`, [r.runId]);
269
+ assert.deepEqual([runRow.status, runRow.reason, runRow.claimed_until], ['done', null, null]);
270
+ const { rows: [n] } = await q(
271
+ `SELECT (SELECT count(*)::int FROM market_watch_log_entries WHERE run_id = $1) AS entries,
272
+ (SELECT count(*)::int FROM market_watch_log_entries WHERE run_id = $1 AND source_url <> $2) AS unsourced,
273
+ (SELECT count(*)::int FROM market_watch_score_snapshots WHERE run_id = $1 AND reason = 'Moved closer.') AS scores,
274
+ (SELECT count(*)::int FROM market_watch_proposals WHERE run_id = $1 AND origin = 'run' AND kind = 'add_item') AS proposals,
275
+ (SELECT count(*)::int FROM market_watch_run_items WHERE run_id = $1 AND status = 'done' AND cost_usd = 0.075) AS items`,
276
+ [r.runId, NEWS]);
277
+ assert.equal(n.entries, r.done, 'one sourced entry per item; the invented link never landed');
278
+ assert.equal(n.unsourced, 0);
279
+ assert.equal(n.scores, r.done);
280
+ assert.equal(n.proposals, 1, 'one proposal per candidate per run, and no new item');
281
+ assert.equal(n.items, r.done);
282
+ assert.equal((await q(`SELECT count(*)::int AS n FROM market_watch_items WHERE project = $1 AND name = 'Newco Agents'`, [P])).rows[0].n, 0);
283
+ assert.equal(mirrored.length, r.done);
284
+ assert.ok(mirrored.every((m) => m.source === `market-watch:${r.runId}` && m.category === 'api' && m.amountUsd === 0.075));
285
+ // Recorded once: a second write of a finished item changes nothing.
286
+ const itemId = (await q(`SELECT item_id FROM market_watch_run_items WHERE run_id = $1 LIMIT 1`, [r.runId])).rows[0].item_id;
287
+ assert.equal((await store.recordItem(pool, P, r.runId, itemId, { ok: false, error: 'x', costUsd: 1 })).recorded, false);
288
+ // Never another project's: the same run and item under another project key writes nothing.
289
+ await assert.rejects(store.recordItem(pool, OTHER, r.runId, itemId, { ok: true, entries: [], scores: [], candidates: [], costUsd: 0 }), /cross_project/);
290
+ // The run left no open row behind for the next tick to resume.
291
+ assert.equal(await store.openRun(pool, P), null);
292
+ });
293
+
294
+ await check('one open run per project, and the key must be a secret-box blob', async () => {
295
+ await q(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'queued', 'manual')`, [OTHER]);
296
+ await refused(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'running', 'poller')`, [OTHER], 'a second open run');
297
+ await refused(`INSERT INTO market_watch_settings (project, anthropic_key_enc, anthropic_key_last4) VALUES ($1, 'sk-ant-plaintext', 'text')`, [OTHER], 'a plaintext key');
298
+ });
299
+ } finally {
300
+ await cleanup();
301
+ await pool.end();
302
+ }
303
+
304
+ if (failed) {
305
+ console.log(`market_watch_data_db.mjs: ${failed} failed`);
306
+ process.exit(1);
307
+ }
308
+ console.log('market_watch_data_db.mjs: all passed');