@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,340 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/db.js — the read queries behind the Market Watch screens (task
4
+ // 1004936, goal 1000137). The pages (tasks 1004938–1004941) and the weekly run (task
5
+ // 1004937) read through these. The weekly run's own bookkeeping (its run row, its lease,
6
+ // the item results it writes) is in store.js; nothing else spells a market_watch_* query.
7
+ //
8
+ // Every read takes a pool and the project key (seed.js projectKey: the instance's public
9
+ // origin) and filters on it, so one project can never read another's rows, even on a
10
+ // shared database.
11
+ //
12
+ // DERIVED, NOT STORED. The screens show an item's level and streak, a sparkline per
13
+ // dimension, this month's spend and when the next run is due. None of those is a column:
14
+ // each is computed here from the runs, entries and snapshots, by a pure function below
15
+ // that is tested without a database. A stored copy would be a second number to keep true.
16
+ //
17
+ // - a run's number ("run 8") counts the runs that RAN (done or partial), oldest first;
18
+ // a skipped or failed run is a dash on the settings strip and takes no number.
19
+ // - an item's level is the number of numbered runs that found something on it (at
20
+ // least 1 once it is tracked); its streak is how many of the latest numbered runs in a
21
+ // row did.
22
+ // - a dimension's sparkline has one point per numbered run the item was scored in;
23
+ // a dimension that run did not score is null, never a carried-forward value.
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+ const RAN = new Set(['done', 'partial']);
27
+ const CADENCE_DAYS = Object.freeze({ weekly: 7, biweekly: 14, monthly: 30 });
28
+ // The settings an unconfigured project reads: the watch is off until an owner turns it on.
29
+ const DEFAULT_SETTINGS = Object.freeze({ enabled: false, monthly_cap_usd: 20, cadence: 'weekly', configured: false });
30
+
31
+ function need(project) {
32
+ if (typeof project !== 'string' || !project) throw new Error('market-watch db: a project key is required');
33
+ return project;
34
+ }
35
+ const num = (v) => (v == null ? null : Number(v));
36
+
37
+ // ---- pure helpers ---------------------------------------------------------
38
+
39
+ // numberRuns(runs) — runs in any order → the same rows, oldest first, each with `n` (its
40
+ // run number) when it ran and null when it did not.
41
+ function numberRuns(runs) {
42
+ const sorted = [...runs].sort((a, b) => (new Date(a.created_at) - new Date(b.created_at)) || (Number(a.id) - Number(b.id)));
43
+ let n = 0;
44
+ return sorted.map((r) => ({ ...r, n: RAN.has(r.status) ? ++n : null }));
45
+ }
46
+
47
+ // progress(numberedRuns, runIdsWithFindings) → { level, streak } for one item.
48
+ function progress(numbered, foundIn) {
49
+ const found = new Set([...foundIn].map(String));
50
+ const ran = numbered.filter((r) => r.n != null);
51
+ const level = Math.max(1, ran.filter((r) => found.has(String(r.id))).length);
52
+ let streak = 0;
53
+ for (let i = ran.length - 1; i >= 0 && found.has(String(ran[i].id)); i -= 1) streak += 1;
54
+ return { level, streak };
55
+ }
56
+
57
+ // sparklines(rows, numberedRuns) → { runs: [{ id, n, date }], series: { <dim>: [v|null] } }
58
+ // rows: { run_id, dimension, value }. Points are the numbered runs that scored the item.
59
+ function sparklines(rows, numbered) {
60
+ const scoredIn = new Set(rows.map((r) => String(r.run_id)));
61
+ const runs = numbered.filter((r) => r.n != null && scoredIn.has(String(r.id)))
62
+ .map((r) => ({ id: Number(r.id), n: r.n, date: r.created_at }));
63
+ const at = new Map(runs.map((r, i) => [String(r.id), i]));
64
+ const series = {};
65
+ for (const r of rows) {
66
+ const i = at.get(String(r.run_id));
67
+ if (i === undefined) continue;
68
+ if (!series[r.dimension]) series[r.dimension] = runs.map(() => null);
69
+ series[r.dimension][i] = num(r.value);
70
+ }
71
+ return { runs, series };
72
+ }
73
+
74
+ // weekStreak(asks, answeredAskIds) — asks newest first, each { id, open }. How many asks
75
+ // in a row the member answered, counting back from the newest. An open ask they have not
76
+ // answered yet does not break the streak: they still can.
77
+ function weekStreak(asks, answered) {
78
+ const a = new Set([...answered].map(String));
79
+ let streak = 0;
80
+ for (let i = 0; i < asks.length; i += 1) {
81
+ if (a.has(String(asks[i].id))) { streak += 1; continue; }
82
+ if (i === 0 && asks[i].open) continue;
83
+ break;
84
+ }
85
+ return streak;
86
+ }
87
+
88
+ // monthBounds(now) → [start, end) of now's calendar month in UTC: the cap's window.
89
+ function monthBounds(now = new Date()) {
90
+ const d = new Date(now);
91
+ return [new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))];
92
+ }
93
+
94
+ // nextRunDue(lastRunAt, cadence) → Date | null. null: no run yet, so one is due now.
95
+ function nextRunDue(lastRunAt, cadence) {
96
+ const days = CADENCE_DAYS[cadence];
97
+ if (!days) throw new Error(`market-watch: unknown cadence ${cadence}`);
98
+ if (!lastRunAt) return null;
99
+ return new Date(new Date(lastRunAt).getTime() + days * DAY_MS);
100
+ }
101
+
102
+ // ---- reads ----------------------------------------------------------------
103
+
104
+ const RUNS_SQL = `SELECT id, status, reason, trigger, created_at, started_at, finished_at
105
+ FROM market_watch_runs WHERE project = $1`;
106
+
107
+ async function numberedRuns(pool, { project }) {
108
+ const { rows } = await pool.query(RUNS_SQL, [need(project)]);
109
+ return numberRuns(rows);
110
+ }
111
+
112
+ const ITEMS_SQL = `
113
+ SELECT i.id, i.kind, i.name, i.url, i.category, i.description, i.why_tracked, i.facts,
114
+ i.status, i.added_via, i.created_at,
115
+ (SELECT s.value FROM market_watch_score_snapshots s
116
+ JOIN market_watch_runs r ON r.id = s.run_id
117
+ WHERE s.item_id = i.id AND s.dimension = 'overall'
118
+ ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS overall,
119
+ (SELECT max(e.found_on) FROM market_watch_log_entries e WHERE e.item_id = i.id) AS last_found_on
120
+ FROM market_watch_items i
121
+ WHERE i.project = $1
122
+ AND ($2::text IS NULL OR i.kind = $2)
123
+ AND ($3::boolean OR i.status <> 'removed')
124
+ AND ($4::bigint IS NULL OR i.id = $4)
125
+ ORDER BY i.kind, overall DESC NULLS LAST, i.name`;
126
+
127
+ const FOUND_IN_SQL = `
128
+ SELECT DISTINCT e.item_id, e.run_id
129
+ FROM market_watch_log_entries e
130
+ JOIN market_watch_items i ON i.id = e.item_id
131
+ WHERE i.project = $1 AND e.run_id IS NOT NULL AND ($2::bigint IS NULL OR e.item_id = $2)`;
132
+
133
+ // listItems — the home desk: every tracked item with its latest overall score, last
134
+ // finding, level and streak.
135
+ async function listItems(pool, { project, kind = null, includeRemoved = false, id = null } = {}) {
136
+ need(project);
137
+ const [{ rows: items }, { rows: found }, runs] = await Promise.all([
138
+ pool.query(ITEMS_SQL, [project, kind, includeRemoved, id]),
139
+ pool.query(FOUND_IN_SQL, [project, id]),
140
+ numberedRuns(pool, { project }),
141
+ ]);
142
+ const byItem = new Map();
143
+ for (const f of found) {
144
+ const k = String(f.item_id);
145
+ if (!byItem.has(k)) byItem.set(k, []);
146
+ byItem.get(k).push(f.run_id);
147
+ }
148
+ return items.map((it) => ({
149
+ ...it,
150
+ id: Number(it.id),
151
+ overall: num(it.overall),
152
+ ...progress(runs, byItem.get(String(it.id)) || []),
153
+ }));
154
+ }
155
+
156
+ // getItem — one item, with removed ones included (its page still opens).
157
+ async function getItem(pool, { project, id }) {
158
+ const rows = await listItems(pool, { project, includeRemoved: true, id });
159
+ return rows[0] || null;
160
+ }
161
+
162
+ const CASE_FILE_SQL = `
163
+ SELECT e.id, e.run_id, e.type, e.found_on, e.takeaway, e.dimension, e.source_url,
164
+ e.source_label, e.confidence, e.pinned, e.created_at
165
+ FROM market_watch_log_entries e
166
+ JOIN market_watch_items i ON i.id = e.item_id
167
+ WHERE i.project = $1 AND e.item_id = $2
168
+ ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
169
+
170
+ // caseFile — the item page's sortable table: found, run, type, takeaway, confidence, source.
171
+ async function caseFile(pool, { project, itemId }) {
172
+ need(project);
173
+ const [{ rows }, runs] = await Promise.all([
174
+ pool.query(CASE_FILE_SQL, [project, itemId]),
175
+ numberedRuns(pool, { project }),
176
+ ]);
177
+ const runN = new Map(runs.map((r) => [String(r.id), r.n]));
178
+ return rows.map((e) => ({ ...e, id: Number(e.id), run: e.run_id == null ? null : runN.get(String(e.run_id)) ?? null }));
179
+ }
180
+
181
+ const SCORES_SQL = `
182
+ SELECT s.run_id, s.dimension, s.value
183
+ FROM market_watch_score_snapshots s
184
+ JOIN market_watch_items i ON i.id = s.item_id
185
+ WHERE i.project = $1 AND s.item_id = $2`;
186
+
187
+ // scoreHistory — the item page's sparklines, one per dimension plus 'overall'.
188
+ async function scoreHistory(pool, { project, itemId }) {
189
+ need(project);
190
+ const [{ rows }, runs] = await Promise.all([
191
+ pool.query(SCORES_SQL, [project, itemId]),
192
+ numberedRuns(pool, { project }),
193
+ ]);
194
+ return sparklines(rows, runs);
195
+ }
196
+
197
+ const RULES_SQL = `
198
+ SELECT id, version, status, search_queries, sources, rubric, instructions, summary, reason,
199
+ author_id, approved_by, approved_at, created_at
200
+ FROM market_watch_rule_versions WHERE project = $1 ORDER BY version`;
201
+
202
+ // rules — the rules page: every version for the slider, the one in use, and the proposed next.
203
+ async function rules(pool, { project }) {
204
+ const { rows } = await pool.query(RULES_SQL, [need(project)]);
205
+ const versions = rows.map((r) => ({ ...r, id: Number(r.id) }));
206
+ return {
207
+ versions,
208
+ active: versions.find((v) => v.status === 'active') || null,
209
+ proposed: versions.find((v) => v.status === 'proposed') || null,
210
+ };
211
+ }
212
+
213
+ const PROPOSALS_SQL = `
214
+ SELECT id, kind, origin, item_id, log_entry_id, rule_version_id, answer_id, payload, reason,
215
+ proposed_by, status, decided_by, decided_at, created_at
216
+ FROM market_watch_proposals
217
+ WHERE project = $1 AND ($2::text IS NULL OR status = $2) AND ($3::bigint IS NULL OR item_id = $3)
218
+ ORDER BY created_at DESC, id DESC
219
+ LIMIT $4`;
220
+
221
+ // proposals — the government's queue (open by default), or one item's.
222
+ async function proposals(pool, { project, status = 'open', itemId = null, limit = 100 } = {}) {
223
+ const { rows } = await pool.query(PROPOSALS_SQL, [need(project), status, itemId, limit]);
224
+ return rows.map((p) => ({ ...p, id: Number(p.id) }));
225
+ }
226
+
227
+ const WEEK_ASKS_SQL = `
228
+ SELECT a.id, a.week_of, a.question_key, a.prompt, a.options, a.closes_at,
229
+ COALESCE(json_agg(json_build_object('choice', t.choice, 'taps', t.n) ORDER BY t.n DESC, t.choice)
230
+ FILTER (WHERE t.choice IS NOT NULL), '[]'::json) AS tallies
231
+ FROM market_watch_weekly_asks a
232
+ LEFT JOIN (SELECT ask_id, choice, count(*)::int AS n FROM market_watch_ask_answers GROUP BY ask_id, choice) t
233
+ ON t.ask_id = a.id
234
+ WHERE a.project = $1 AND a.week_of = $2
235
+ GROUP BY a.id
236
+ ORDER BY a.question_key`;
237
+
238
+ // weeklyAsk — the week's card: its questions, their chips, and how many tapped each.
239
+ async function weeklyAsk(pool, { project, weekOf }) {
240
+ const { rows } = await pool.query(WEEK_ASKS_SQL, [need(project), weekOf]);
241
+ return rows.map((a) => ({ ...a, id: Number(a.id) }));
242
+ }
243
+
244
+ const ASK_HISTORY_SQL = `
245
+ SELECT a.id, a.week_of, (a.closes_at IS NULL OR a.closes_at > $3) AS open,
246
+ EXISTS (SELECT 1 FROM market_watch_ask_answers x WHERE x.ask_id = a.id AND x.builder_id = $2) AS answered
247
+ FROM market_watch_weekly_asks a
248
+ WHERE a.project = $1
249
+ ORDER BY a.week_of DESC, a.id DESC`;
250
+
251
+ // askStreak — the card's "4 weeks in a row" for one member. A week counts once however
252
+ // many of its questions they answered.
253
+ async function askStreak(pool, { project, builderId, now = new Date() }) {
254
+ const { rows } = await pool.query(ASK_HISTORY_SQL, [need(project), builderId, now]);
255
+ const weeks = [];
256
+ const byWeek = new Map();
257
+ for (const r of rows) {
258
+ const k = String(r.week_of instanceof Date ? r.week_of.toISOString().slice(0, 10) : r.week_of);
259
+ if (!byWeek.has(k)) { byWeek.set(k, { id: k, open: false, answered: false }); weeks.push(byWeek.get(k)); }
260
+ const w = byWeek.get(k);
261
+ w.open = w.open || r.open === true;
262
+ w.answered = w.answered || r.answered === true;
263
+ }
264
+ return weekStreak(weeks, weeks.filter((w) => w.answered).map((w) => w.id));
265
+ }
266
+
267
+ const SETTINGS_SQL = `SELECT enabled, monthly_cap_usd, cadence, updated_by, updated_at
268
+ FROM market_watch_settings WHERE project = $1`;
269
+ const MONTH_SPEND_SQL = `
270
+ SELECT COALESCE(sum(ri.cost_usd), 0) AS spent
271
+ FROM market_watch_run_items ri
272
+ JOIN market_watch_runs r ON r.id = ri.run_id
273
+ WHERE r.project = $1 AND ri.finished_at >= $2 AND ri.finished_at < $3`;
274
+ const RECENT_RUNS_SQL = `
275
+ SELECT r.id, r.status, r.reason, r.trigger, r.created_at, r.started_at, r.finished_at,
276
+ COALESCE(sum(ri.cost_usd), 0) AS cost_usd,
277
+ count(ri.item_id)::int AS items_total,
278
+ (count(ri.item_id) FILTER (WHERE ri.status = 'done'))::int AS items_done
279
+ FROM market_watch_runs r
280
+ LEFT JOIN market_watch_run_items ri ON ri.run_id = r.id
281
+ WHERE r.project = $1 AND r.trigger <> 'seed'
282
+ GROUP BY r.id
283
+ ORDER BY r.created_at DESC, r.id DESC
284
+ LIMIT $2`;
285
+
286
+ // monthSpend — what Market Watch spent this calendar month (UTC), from the per-item costs.
287
+ async function monthSpend(pool, { project, now = new Date() }) {
288
+ const [start, end] = monthBounds(now);
289
+ const { rows: [r] } = await pool.query(MONTH_SPEND_SQL, [need(project), start, end]);
290
+ return Number(r.spent);
291
+ }
292
+
293
+ // settingsView — the fuel gauge: the switch, cap, cadence, this month's spend, the strip
294
+ // of recent runs (a skipped run is a row with its reason) and when the next one is due.
295
+ async function settingsView(pool, { project, now = new Date(), recent = 8 } = {}) {
296
+ need(project);
297
+ const [{ rows: [s] }, spent, { rows: runs }] = await Promise.all([
298
+ pool.query(SETTINGS_SQL, [project]),
299
+ monthSpend(pool, { project, now }),
300
+ pool.query(RECENT_RUNS_SQL, [project, recent]),
301
+ ]);
302
+ const settings = s
303
+ ? { enabled: s.enabled, monthly_cap_usd: Number(s.monthly_cap_usd), cadence: s.cadence, configured: true, updated_by: s.updated_by, updated_at: s.updated_at }
304
+ : { ...DEFAULT_SETTINGS };
305
+ const recentRuns = runs.map((r) => ({ ...r, id: Number(r.id), cost_usd: Number(r.cost_usd) }));
306
+ return {
307
+ settings,
308
+ spentThisMonthUsd: spent,
309
+ recentRuns,
310
+ nextRunDue: nextRunDue(recentRuns[0] ? recentRuns[0].created_at : null, settings.cadence),
311
+ };
312
+ }
313
+
314
+ const REMAINING_SQL = `
315
+ SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked
316
+ FROM market_watch_items i
317
+ JOIN market_watch_runs r ON r.id = $2 AND r.project = i.project
318
+ WHERE i.project = $1 AND i.status = 'active'
319
+ AND NOT EXISTS (SELECT 1 FROM market_watch_run_items ri
320
+ WHERE ri.run_id = r.id AND ri.item_id = i.id AND ri.status IN ('done', 'error'))
321
+ ORDER BY i.id`;
322
+
323
+ // remainingItems — what a (resumed) run still has to do: the project's active items with
324
+ // no finished row in this run. An item already done is never returned, so it is never
325
+ // researched, or charged, twice (ADR 0393 §2). An item that ERRORED is finished too: the
326
+ // model ran and was paid for (task 1004937), so a resume does not buy it a second call.
327
+ // Each row carries its project, so the run can refuse one that is not its own.
328
+ async function remainingItems(pool, { project, runId }) {
329
+ const { rows } = await pool.query(REMAINING_SQL, [need(project), runId]);
330
+ return rows.map((r) => ({ ...r, id: Number(r.id) }));
331
+ }
332
+
333
+ module.exports = {
334
+ // reads
335
+ numberedRuns, listItems, getItem, caseFile, scoreHistory, rules, proposals,
336
+ weeklyAsk, askStreak, monthSpend, settingsView, remainingItems,
337
+ // pure
338
+ numberRuns, progress, sparklines, weekStreak, monthBounds, nextRunDue,
339
+ CADENCE_DAYS, DEFAULT_SETTINGS,
340
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/key.js — the project's own Anthropic key (task 1004937, ADR 0394 §1).
4
+ //
5
+ // The weekly run calls the Messages API on the PROJECT's key, never on ours and never on a
6
+ // builder's: the project's account pays the provider directly, and Bongos holds no money
7
+ // (ADR 0385). The owner pastes it into Market Watch settings (the page is task 1004938);
8
+ // it is stored encrypted with the kernel secret box, exactly as ai-studio stores a
9
+ // builder's provider keys (task 1004919): a "v1.<iv>.<tag>.<ct>" blob plus the last four
10
+ // characters, which are all the page ever shows.
11
+ //
12
+ // THE ONE ACCESSOR. readProjectKey is the only function that turns the blob back into a
13
+ // key, and only the run calls it, right before it builds the client. It never logs the
14
+ // key, never returns it to a route, and answers null (not a throw) for "no usable key":
15
+ // a missing row, an empty column, or a blob the master key cannot open. The run turns
16
+ // that null into a skipped run with reason 'no_key', visible on the settings strip.
17
+ //
18
+ // sealProjectKey is the writer's half, here so the settings route (task 1004938) stores
19
+ // exactly the shape this file reads. Pure apart from the injected encrypt/decrypt, so a
20
+ // test runs it with no master key and no database.
21
+
22
+ const MIN_KEY = 16;
23
+ const MAX_KEY = 512;
24
+
25
+ const KEY_SQL = `SELECT anthropic_key_enc FROM market_watch_settings WHERE project = $1`;
26
+
27
+ function defaultBox() {
28
+ return require('../../src/module-api').secretBox;
29
+ }
30
+
31
+ // sealProjectKey(raw, { encrypt }) → { key_enc, last4 } | { error }. Never echoes the key.
32
+ function sealProjectKey(raw, { encrypt } = {}) {
33
+ if (typeof raw !== 'string') return { error: 'key_required' };
34
+ const key = raw.trim();
35
+ if (!key) return { error: 'key_required' };
36
+ if (key.length < MIN_KEY || key.length > MAX_KEY) return { error: 'key_wrong_length' };
37
+ if (/\s/.test(key)) return { error: 'key_has_spaces' };
38
+ const enc = (encrypt || ((s) => defaultBox().encrypt(s)))(key);
39
+ return { key_enc: enc, last4: key.slice(-4) };
40
+ }
41
+
42
+ // readProjectKey(pool, project, { decrypt }) → the plaintext key, or null.
43
+ // Fails closed to null: no key means no run, never a run on some other key.
44
+ async function readProjectKey(pool, project, { decrypt } = {}) {
45
+ if (typeof project !== 'string' || !project) return null;
46
+ const { rows } = await pool.query(KEY_SQL, [project]);
47
+ const blob = rows[0] && rows[0].anthropic_key_enc;
48
+ if (typeof blob !== 'string' || !blob) return null;
49
+ try {
50
+ const key = (decrypt || ((b) => defaultBox().decrypt(b)))(blob);
51
+ return typeof key === 'string' && key.trim() ? key.trim() : null;
52
+ } catch (_) {
53
+ // A wrong or missing master key, or a tampered blob. Not a key we can use.
54
+ return null;
55
+ }
56
+ }
57
+
58
+ module.exports = { readProjectKey, sealProjectKey, KEY_SQL };
@@ -0,0 +1,249 @@
1
+ -- market_watch_001_tables.sql - the Market Watch data model (task 1004936, goal 1000137).
2
+ --
3
+ -- What the owner-approved screens (docs/design/mocks/market-watch/) and the run model of
4
+ -- ADR 0393 need stored. Everything else they show is DERIVED in modules/market-watch/db.js
5
+ -- (an item's level and streak, a dimension's sparkline, this month's spend), so there is
6
+ -- one source for each number and nothing to keep in step.
7
+ --
8
+ -- market_watch_settings one row per project: on/off, the monthly AI cap, cadence
9
+ -- market_watch_rule_versions the watch rules as versioned rows; 'proposed' is the next one
10
+ -- market_watch_items what is watched: competitor | partner | topic
11
+ -- market_watch_runs one row per run, including skipped ones (ADR 0393 s3-4)
12
+ -- market_watch_run_items per-item progress inside a run: what makes a cut-off run
13
+ -- resumable without charging an item twice (ADR 0393 s2)
14
+ -- market_watch_log_entries the case file: found, run, type, takeaway, confidence, source
15
+ -- market_watch_score_snapshots one value per item, dimension and run: the sparklines
16
+ -- market_watch_weekly_asks the weekly question to every member
17
+ -- market_watch_ask_answers each member's taps on it
18
+ -- market_watch_proposals a change asked for by a member or born of a weekly-ask tap
19
+ --
20
+ -- PROJECT. Every project is its own instance with its own database (ADR 0393 Context), so a
21
+ -- row's `project` is that instance's public origin, read from branding at write time and
22
+ -- never hardcoded. It is still a column so a read can never mix two projects if a database
23
+ -- is ever shared, and so the hub can tell its own seed rows from anything else.
24
+ --
25
+ -- PEOPLE. A person is a builders(id). Seeded rows have no author (NULL): the 2026-10-28
26
+ -- research was not a member's act, and naming someone here would be inventing a record.
27
+ --
28
+ -- Additive and market_watch_-prefixed (ADR 0083 s5, fitness checkModuleMigrationsAdditive).
29
+ -- Idempotent: every statement survives a re-run on a database that already has it.
30
+
31
+ BEGIN;
32
+
33
+ -- The project's switch, cap and cadence (the settings screen). `enabled` is the owner's
34
+ -- watch switch, distinct from the module being enabled: the module can be on with the
35
+ -- watch paused. The cap is in US dollars, like cost_log.
36
+ CREATE TABLE IF NOT EXISTS market_watch_settings (
37
+ project text PRIMARY KEY,
38
+ enabled boolean NOT NULL DEFAULT false,
39
+ monthly_cap_usd numeric(10,2) NOT NULL DEFAULT 20 CHECK (monthly_cap_usd >= 0),
40
+ cadence text NOT NULL DEFAULT 'weekly'
41
+ CHECK (cadence IN ('weekly', 'biweekly', 'monthly')),
42
+ updated_by bigint REFERENCES builders(id),
43
+ updated_at timestamptz NOT NULL DEFAULT now()
44
+ );
45
+
46
+ -- The watch rules. One row per version; the rules page's slider walks them by `version`.
47
+ -- Exactly one 'active' version per project (the rules in use) and at most one 'proposed'
48
+ -- (the next one), each held by a partial unique index rather than by every writer's care.
49
+ -- rubric is { "<kind>": [ { "key", "label", "definition", "weight" } ] }: competitors and
50
+ -- partners are scored on different dimensions.
51
+ CREATE TABLE IF NOT EXISTS market_watch_rule_versions (
52
+ id bigserial PRIMARY KEY,
53
+ project text NOT NULL,
54
+ version integer NOT NULL CHECK (version >= 1),
55
+ status text NOT NULL DEFAULT 'proposed'
56
+ CHECK (status IN ('proposed', 'active', 'superseded', 'rejected')),
57
+ search_queries jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(search_queries) = 'array'),
58
+ sources jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(sources) = 'array'),
59
+ rubric jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(rubric) = 'object'),
60
+ instructions text NOT NULL DEFAULT '',
61
+ summary text NOT NULL DEFAULT '', -- the slider's one line ("+ GitHub releases as a source")
62
+ reason text NOT NULL CHECK (length(btrim(reason)) > 0),
63
+ author_id bigint REFERENCES builders(id), -- NULL: seeded, not a member's act
64
+ approved_by bigint REFERENCES builders(id),
65
+ approved_at timestamptz,
66
+ created_at timestamptz NOT NULL DEFAULT now(),
67
+ UNIQUE (project, version)
68
+ );
69
+ CREATE UNIQUE INDEX IF NOT EXISTS market_watch_rule_versions_one_active
70
+ ON market_watch_rule_versions (project) WHERE status = 'active';
71
+ CREATE UNIQUE INDEX IF NOT EXISTS market_watch_rule_versions_one_proposed
72
+ ON market_watch_rule_versions (project) WHERE status = 'proposed';
73
+
74
+ -- What is watched. Keyed by (project, kind, url, name): the research scored five names both as
75
+ -- a competitor and as a partner, on different rubrics, so they are two items. Removing an
76
+ -- item is status 'removed', never a DELETE: its log and scores stay readable, and a re-run
77
+ -- of the seed cannot bring it back (ON CONFLICT DO NOTHING finds the removed row).
78
+ CREATE TABLE IF NOT EXISTS market_watch_items (
79
+ id bigserial PRIMARY KEY,
80
+ project text NOT NULL,
81
+ kind text NOT NULL CHECK (kind IN ('competitor', 'partner', 'topic')),
82
+ name text NOT NULL CHECK (length(btrim(name)) > 0),
83
+ url text NOT NULL, -- a topic has none: ''
84
+ category text NOT NULL DEFAULT '',
85
+ description text NOT NULL DEFAULT '', -- "what it is", one line
86
+ why_tracked text NOT NULL DEFAULT '',
87
+ facts jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(facts) = 'object'),
88
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'removed')),
89
+ added_via text NOT NULL DEFAULT 'member'
90
+ CHECK (added_via IN ('seed', 'member', 'proposal', 'run')),
91
+ added_by bigint REFERENCES builders(id),
92
+ created_at timestamptz NOT NULL DEFAULT now(),
93
+ updated_at timestamptz NOT NULL DEFAULT now(),
94
+ UNIQUE (project, kind, url, name)
95
+ );
96
+ CREATE INDEX IF NOT EXISTS market_watch_items_project_status
97
+ ON market_watch_items (project, status, kind);
98
+
99
+ -- One row per run, INCLUDING the runs that never started: a skipped run is a row with a
100
+ -- reason, never only a log line (ADR 0393 "What R04 must build" s4). The settings strip
101
+ -- reads these, and a dash there is a 'skipped' row.
102
+ -- status: queued -> running -> done | partial | skipped | failed
103
+ -- reason: why a run is partial, skipped or failed (NULL when done)
104
+ -- trigger: 'poller' (the weekly clock), 'manual' (an owner's "run now"), 'seed' (the
105
+ -- 2026-10-28 research loaded as run zero, so seeded scores carry a run id)
106
+ -- claimed_until is the single-flight lease: the poller takes a run with
107
+ -- UPDATE ... WHERE status IN ('queued','running') AND claimed_until < now() RETURNING, so a
108
+ -- second process on the same database cannot run it too, and a run whose process died
109
+ -- mid-way is picked up again once its lease lapses.
110
+ CREATE TABLE IF NOT EXISTS market_watch_runs (
111
+ id bigserial PRIMARY KEY,
112
+ project text NOT NULL,
113
+ status text NOT NULL DEFAULT 'queued'
114
+ CHECK (status IN ('queued', 'running', 'done', 'partial', 'skipped', 'failed')),
115
+ reason text CHECK (reason IS NULL OR reason IN
116
+ ('off', 'no_key', 'cap_reached', 'spend_unreadable', 'timeout', 'error')),
117
+ trigger text NOT NULL DEFAULT 'poller' CHECK (trigger IN ('poller', 'manual', 'seed')),
118
+ rule_version_id bigint REFERENCES market_watch_rule_versions(id),
119
+ cap_usd numeric(10,2), -- the cap as it stood when the run was planned
120
+ spent_before_usd numeric(10,4), -- this month's spend read before the run
121
+ claimed_until timestamptz,
122
+ started_at timestamptz,
123
+ finished_at timestamptz,
124
+ created_at timestamptz NOT NULL DEFAULT now(),
125
+ CHECK (status NOT IN ('skipped', 'partial', 'failed') OR reason IS NOT NULL)
126
+ );
127
+ CREATE INDEX IF NOT EXISTS market_watch_runs_project_created
128
+ ON market_watch_runs (project, created_at DESC);
129
+ -- The 2026-10-28 research is ONE run per project: what makes the seed idempotent.
130
+ CREATE UNIQUE INDEX IF NOT EXISTS market_watch_runs_one_seed
131
+ ON market_watch_runs (project) WHERE trigger = 'seed';
132
+
133
+ -- Per-item progress inside a run (ADR 0393 s2, "resumable"). One row per (run, item),
134
+ -- written as each item finishes. A resumed run does only the rows that are not 'done',
135
+ -- and an item's cost is recorded here once: cost_usd is set in the same statement that
136
+ -- moves the row to 'done', so a retry finds it done and never prices it again. The
137
+ -- cost_log projection (source market-watch:<run id>) keys on (run, item) for the same
138
+ -- reason.
139
+ CREATE TABLE IF NOT EXISTS market_watch_run_items (
140
+ run_id bigint NOT NULL REFERENCES market_watch_runs(id),
141
+ item_id bigint NOT NULL REFERENCES market_watch_items(id),
142
+ status text NOT NULL DEFAULT 'pending'
143
+ CHECK (status IN ('pending', 'running', 'done', 'error', 'skipped')),
144
+ error text, -- refusal | max_tokens | tool_error | ... (ADR 0393 s1)
145
+ cost_usd numeric(10,4) CHECK (cost_usd IS NULL OR cost_usd >= 0),
146
+ started_at timestamptz,
147
+ finished_at timestamptz,
148
+ PRIMARY KEY (run_id, item_id),
149
+ CHECK (status <> 'done' OR cost_usd IS NOT NULL)
150
+ );
151
+ CREATE INDEX IF NOT EXISTS market_watch_run_items_open
152
+ ON market_watch_run_items (run_id) WHERE status <> 'done';
153
+
154
+ -- The case file. confidence is 1 (low) ... 3 (high), the mock's three dots. source_url is
155
+ -- required: an entry without a source is not an entry (ADR 0393 s1 drops them in code;
156
+ -- this is the floor no future writer can step under). One takeaway per item and run is
157
+ -- stored once, so a resumed run that rewrites an item's findings cannot duplicate them.
158
+ CREATE TABLE IF NOT EXISTS market_watch_log_entries (
159
+ id bigserial PRIMARY KEY,
160
+ item_id bigint NOT NULL REFERENCES market_watch_items(id),
161
+ run_id bigint REFERENCES market_watch_runs(id),
162
+ type text NOT NULL CHECK (type IN ('insight', 'news', 'datapoint')),
163
+ found_on date NOT NULL DEFAULT current_date,
164
+ takeaway text NOT NULL CHECK (length(btrim(takeaway)) > 0),
165
+ dimension text, -- the rubric key it bears on, if any
166
+ source_url text NOT NULL CHECK (source_url ~ '^https?://'),
167
+ source_label text NOT NULL DEFAULT '',
168
+ confidence smallint NOT NULL CHECK (confidence BETWEEN 1 AND 3),
169
+ pinned boolean NOT NULL DEFAULT false,
170
+ created_at timestamptz NOT NULL DEFAULT now()
171
+ );
172
+ CREATE UNIQUE INDEX IF NOT EXISTS market_watch_log_entries_once
173
+ ON market_watch_log_entries (item_id, run_id, md5(takeaway));
174
+ CREATE INDEX IF NOT EXISTS market_watch_log_entries_item
175
+ ON market_watch_log_entries (item_id, found_on DESC, id DESC);
176
+
177
+ -- Scores over time: one value per (item, dimension, run). 'overall' is a dimension like
178
+ -- the others, so the likeness line and the five dimension lines are one query.
179
+ CREATE TABLE IF NOT EXISTS market_watch_score_snapshots (
180
+ id bigserial PRIMARY KEY,
181
+ item_id bigint NOT NULL REFERENCES market_watch_items(id),
182
+ run_id bigint NOT NULL REFERENCES market_watch_runs(id),
183
+ dimension text NOT NULL CHECK (length(btrim(dimension)) > 0),
184
+ value numeric(4,1) NOT NULL CHECK (value BETWEEN 0 AND 10),
185
+ created_at timestamptz NOT NULL DEFAULT now(),
186
+ UNIQUE (item_id, run_id, dimension)
187
+ );
188
+
189
+ -- The weekly question. question_key names which of the card's questions this is
190
+ -- ('rival' = what counts as a competitor, 'watch' = what else should we watch); options
191
+ -- are the chips, each { "label", "example" }.
192
+ CREATE TABLE IF NOT EXISTS market_watch_weekly_asks (
193
+ id bigserial PRIMARY KEY,
194
+ project text NOT NULL,
195
+ week_of date NOT NULL,
196
+ question_key text NOT NULL CHECK (length(btrim(question_key)) > 0),
197
+ prompt text NOT NULL CHECK (length(btrim(prompt)) > 0),
198
+ options jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(options) = 'array'),
199
+ closes_at timestamptz,
200
+ created_at timestamptz NOT NULL DEFAULT now(),
201
+ UNIQUE (project, week_of, question_key)
202
+ );
203
+
204
+ -- A member's taps. A chip is a `choice`; "name one" and "add a topic" carry free_text.
205
+ -- One row per member and choice, so a second tap on the same chip is a no-op.
206
+ CREATE TABLE IF NOT EXISTS market_watch_ask_answers (
207
+ id bigserial PRIMARY KEY,
208
+ ask_id bigint NOT NULL REFERENCES market_watch_weekly_asks(id),
209
+ builder_id bigint NOT NULL REFERENCES builders(id),
210
+ choice text NOT NULL CHECK (length(btrim(choice)) > 0),
211
+ free_text text,
212
+ created_at timestamptz NOT NULL DEFAULT now(),
213
+ UNIQUE (ask_id, builder_id, choice)
214
+ );
215
+
216
+ -- A change someone asked for. origin 'member' is the item page's "Propose a change" or
217
+ -- the rules page; 'weekly_ask' is born of a tap (answer_id says which). What it changes
218
+ -- is `kind` plus the row it points at; `payload` carries the specifics (a new score, the
219
+ -- source to add, the corrected text, the proposed rules version's id).
220
+ CREATE TABLE IF NOT EXISTS market_watch_proposals (
221
+ id bigserial PRIMARY KEY,
222
+ project text NOT NULL,
223
+ kind text NOT NULL CHECK (kind IN
224
+ ('rule_change', 'add_item', 'remove_item', 'rescore', 'move_kind', 'add_source', 'correct_entry')),
225
+ origin text NOT NULL CHECK (origin IN ('member', 'weekly_ask')),
226
+ item_id bigint REFERENCES market_watch_items(id),
227
+ log_entry_id bigint REFERENCES market_watch_log_entries(id),
228
+ rule_version_id bigint REFERENCES market_watch_rule_versions(id),
229
+ answer_id bigint REFERENCES market_watch_ask_answers(id),
230
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(payload) = 'object'),
231
+ reason text NOT NULL DEFAULT '',
232
+ proposed_by bigint REFERENCES builders(id),
233
+ status text NOT NULL DEFAULT 'open'
234
+ CHECK (status IN ('open', 'approved', 'rejected', 'withdrawn', 'applied')),
235
+ decided_by bigint REFERENCES builders(id),
236
+ decided_at timestamptz,
237
+ created_at timestamptz NOT NULL DEFAULT now(),
238
+ CHECK (origin <> 'weekly_ask' OR answer_id IS NOT NULL),
239
+ CHECK (kind <> 'rule_change' OR rule_version_id IS NOT NULL),
240
+ CHECK (kind NOT IN ('remove_item', 'rescore', 'move_kind', 'add_source') OR item_id IS NOT NULL),
241
+ CHECK (kind <> 'correct_entry' OR log_entry_id IS NOT NULL)
242
+ );
243
+ CREATE INDEX IF NOT EXISTS market_watch_proposals_open
244
+ ON market_watch_proposals (project, created_at DESC) WHERE status = 'open';
245
+ -- One tap is one proposal: re-pooling the answers is a no-op.
246
+ CREATE UNIQUE INDEX IF NOT EXISTS market_watch_proposals_one_per_answer
247
+ ON market_watch_proposals (answer_id) WHERE answer_id IS NOT NULL;
248
+
249
+ COMMIT;