@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,114 @@
1
+ // tests/market_watch_module.mjs — the market-watch module is registered, accepted by
2
+ // the loader, and an instance boots with it present and DISABLED (task 1004935, goal
3
+ // 1000137). DB-free, so it runs in the unit lane.
4
+ //
5
+ // The module mounts no routes yet; task 1004936 gave it its tables and a one-shot hub seed
6
+ // poller (tests/market_watch_data.mjs covers those). What this file
7
+ // pins is everything the later Market Watch tasks will stand on:
8
+ // 1. the manifest validates, ships default:false, and declares spend.requiresPayer;
9
+ // 2. the loader discovers it from the live tree with no error and no skip;
10
+ // 3. the resolved module set has it OFF with no config, and the spend guard refuses
11
+ // to turn it on behind an open join door with no payer;
12
+ // 4. the real platform app boots and its hall reports market-watch present and false;
13
+ // 5. the creation picker does not offer it while it does nothing.
14
+ import assert from 'node:assert/strict';
15
+ import { test, before, after } from 'node:test';
16
+ import http from 'node:http';
17
+ import { readFileSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const KEY = 'market-watch';
22
+ const manifest = JSON.parse(readFileSync(new URL('../modules/market-watch/module.json', import.meta.url), 'utf8'));
23
+ const { validateManifest } = require('../src/module-loader/manifest-schema.js');
24
+ const loader = require('../src/module-loader/loader.js');
25
+ const mods = require('../src/modules.js');
26
+ const bundles = require('../modules/provisioning/starter-bundles.js');
27
+ const scopeMap = require('../src/bongos/module-scope-map.js');
28
+
29
+ test('the manifest validates, ships disabled, declares a payer, and contributes only its data', () => {
30
+ const v = validateManifest(manifest);
31
+ assert.equal(v.valid, true, `manifest errors: ${v.errors.join('; ')}`);
32
+ assert.equal(manifest.key, KEY);
33
+ assert.equal(manifest.default, false, 'shipped is not enabled (ADR 0083)');
34
+ assert.equal(manifest.spend && manifest.spend.requiresPayer, true,
35
+ 'every run spends the project AI budget, so the payer guard is declared before the first route');
36
+ // Task 1004936 added its tables and the one-shot hub seed; task 1004937 the weekly-run
37
+ // poller. There are still no routes.
38
+ assert.deepEqual(manifest.contributes, { migrations: true, pollers: ['hub-seed', 'weekly-run'] },
39
+ 'its tables, the hub seed and the weekly run, nothing to mount');
40
+ });
41
+
42
+ test('the loader discovers it from the live tree — loaded, not an error, not skipped', () => {
43
+ loader._reset();
44
+ const snap = loader.loaderSnapshot();
45
+ assert.ok(snap.loaded.includes(KEY), `loaded: ${snap.loaded.join(', ')}`);
46
+ assert.deepEqual(snap.errors.filter((e) => JSON.stringify(e).includes(KEY)), []);
47
+ assert.deepEqual(snap.skipped.filter((s) => JSON.stringify(s).includes(KEY)), []);
48
+ const reg = loader.discoveredRegistry();
49
+ assert.equal(reg[KEY].default, false);
50
+ assert.equal(mods.metersSpend(reg[KEY]), true, 'the declaration rides through the loader to the spend guard');
51
+ loader._reset();
52
+ });
53
+
54
+ test('with no config it resolves OFF, and isModuleEnabled answers rather than throwing "unknown module"', () => {
55
+ mods._resetForTests && mods._resetForTests();
56
+ assert.ok(mods.knownModules().includes(KEY));
57
+ const set = mods.resolveModules({ registry: mods.effectiveRegistry() });
58
+ assert.equal(set[KEY], false);
59
+ assert.equal(mods.isModuleEnabled(KEY, set), false);
60
+ });
61
+
62
+ test('the spend guard bites it: an open door with no payer cannot enable it; a closed door can', () => {
63
+ const registry = mods.effectiveRegistry();
64
+ const on = { modules: { [KEY]: true } };
65
+ const silent = () => {};
66
+ assert.equal(mods.resolveModules({ instance: on, registry, spendGuard: { joinDoorOpen: true, payer: null }, warn: silent })[KEY], false);
67
+ assert.equal(mods.resolveModules({ instance: on, registry, spendGuard: { joinDoorOpen: false, payer: null }, warn: silent })[KEY], true);
68
+ });
69
+
70
+ test('it has a scope-roster key, and the creation picker does not offer it while it is empty', () => {
71
+ assert.ok(Array.isArray(scopeMap.MODULE_GLOBS[KEY]) && scopeMap.MODULE_GLOBS[KEY].includes('modules/market-watch/'));
72
+ // Every entry is an exact path or a dir prefix (the touches[] convention has no
73
+ // wildcards), and this test's own path resolves to the module's scope.
74
+ for (const g of scopeMap.MODULE_GLOBS[KEY]) assert.ok(!g.includes('*'), `wildcard glob never matches: ${g}`);
75
+ assert.ok(scopeMap.MODULE_GLOBS[KEY].includes('tests/market_watch_module.mjs'));
76
+ assert.ok(bundles.HUB_ONLY_MODULES.includes(KEY));
77
+ assert.ok(!bundles.OPTIONAL_MODULES.some((m) => m.key === KEY));
78
+ assert.ok(!bundles.ALWAYS_ON_CORE.includes(KEY));
79
+ });
80
+
81
+ // --- an instance boots with it present and disabled -------------------------
82
+ let server;
83
+ let port;
84
+ before(async () => {
85
+ loader._reset();
86
+ mods._resetForTests && mods._resetForTests();
87
+ const { buildPlatformApp } = require('../src/platform-server.js');
88
+ server = http.createServer(buildPlatformApp());
89
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
90
+ port = server.address().port;
91
+ });
92
+ after(async () => { if (server) await new Promise((resolve) => server.close(resolve)); });
93
+
94
+ function get(pathname, host) {
95
+ return new Promise((resolve, reject) => {
96
+ const req = http.request({ host: '127.0.0.1', port, path: pathname, method: 'GET', headers: { Host: host } }, (res) => {
97
+ let body = '';
98
+ res.on('data', (c) => (body += c));
99
+ res.on('end', () => resolve({ status: res.statusCode, body }));
100
+ });
101
+ req.on('error', reject);
102
+ req.end();
103
+ });
104
+ }
105
+
106
+ test('the platform boots and its hall reports market-watch present and OFF', async () => {
107
+ const res = await get('/', 'builders.test.local');
108
+ assert.equal(res.status, 200);
109
+ const m = res.body.match(/window\.__MODULES__=(\{.*?\});/);
110
+ assert.ok(m, 'a __MODULES__ assignment is present');
111
+ const enabled = JSON.parse(m[1]).enabled;
112
+ assert.ok(Object.prototype.hasOwnProperty.call(enabled, KEY), 'present: the booted instance knows the key');
113
+ assert.equal(enabled[KEY], false, 'disabled');
114
+ });
@@ -0,0 +1,503 @@
1
+ // tests/market_watch_run.mjs — the weekly Market Watch run with no database and no network
2
+ // (task 1004937, goal 1000137, ADR 0394).
3
+ //
4
+ // The Messages API client is a fake whose `messages.create` records the request it was
5
+ // handed and answers with a scripted response, so every test asserts on the REAL request
6
+ // the run builds (model, web tool caps, the key) and on the run's handling of the reply.
7
+ // The store is an in-memory stand-in with store.js's method names and its two guarantees
8
+ // (an item result is recorded once; a result for another project's item throws
9
+ // cross_project). store.js's own SQL guard is tested at the bottom against a recording
10
+ // pool. Nothing here can make a paid call: no test ever constructs the real SDK.
11
+ //
12
+ // The SQL runs on real Postgres in tests/market_watch_data_db.mjs (INTEGRATION set).
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import { readFileSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const seed = require('../modules/market-watch/seed.js');
20
+ const research = require('../modules/market-watch/research.js');
21
+ const run = require('../modules/market-watch/run.js');
22
+ const store = require('../modules/market-watch/store.js');
23
+ const key = require('../modules/market-watch/key.js');
24
+ const weekly = require('../modules/market-watch/pollers/weekly-run.js');
25
+ const { priceWebSearches, WEB_SEARCH_USD_PER_REQUEST } = require('../src/bongos/llm-pricing.js');
26
+
27
+ const P = 'https://watch.example.test';
28
+ const OTHER = 'https://other.example.test';
29
+ const KEY = 'sk-ant-api03-project-own-key-ABCD';
30
+ const NOW = Date.parse('2026-11-20T10:00:00Z');
31
+ const DAY = 24 * 60 * 60 * 1000;
32
+
33
+ const built = seed.buildSeed({ market: seed.readSeed('market-data.json'), partners: seed.readSeed('partners-data.json') });
34
+
35
+ // --- a fake Messages API ---------------------------------------------------
36
+
37
+ const NEWS_URL = 'https://news.example.com/launch';
38
+ const FETCHED_URL = 'https://blog.example.com/post';
39
+
40
+ function reply(obj, { stop = 'end_turn', usage, extra = [] } = {}) {
41
+ return {
42
+ stop_reason: stop,
43
+ content: [
44
+ { type: 'server_tool_use', id: 's1', name: 'web_search', input: { query: 'x' } },
45
+ { type: 'web_search_tool_result', tool_use_id: 's1', content: [{ type: 'web_search_result', url: NEWS_URL, title: 'Launch' }] },
46
+ { type: 'server_tool_use', id: 'f1', name: 'web_fetch', input: { url: FETCHED_URL } },
47
+ { type: 'web_fetch_tool_result', tool_use_id: 'f1', content: { type: 'web_fetch_result', url: FETCHED_URL, content: {} } },
48
+ ...extra,
49
+ { type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj) },
50
+ ],
51
+ usage: usage || { input_tokens: 20_000, output_tokens: 1_500, server_tool_use: { web_search_requests: 2 } },
52
+ };
53
+ }
54
+ // 20k in × $2/M + 1.5k out × $10/M + 2 searches × $0.01
55
+ const REPLY_COST = 0.075;
56
+
57
+ const GOOD = {
58
+ entries: [
59
+ { type: 'news', takeaway: 'Shipped agent mode to self-hosted customers.', source_url: NEWS_URL, source_label: 'news', confidence: 'high', dimension: 'human_gates' },
60
+ { type: 'insight', takeaway: 'Pricing moved to usage.', source_url: `${FETCHED_URL}/`, source_label: '', confidence: 'medium', dimension: null },
61
+ { type: 'news', takeaway: 'An invented scoop.', source_url: 'https://made-up.example.org/x', source_label: 'x', confidence: 'high', dimension: null },
62
+ ],
63
+ scores: [
64
+ { dimension: 'overall', value: 6.24, reason: 'Closer to us after the self-host launch.' },
65
+ { dimension: 'human_gates', value: 7, reason: 'Adds an approval step.' },
66
+ { dimension: 'not_a_dimension', value: 5, reason: 'x' },
67
+ { dimension: 'open_source_ethos', value: 11, reason: 'out of range' },
68
+ ],
69
+ candidates: [{ name: 'Newco Agents', url: 'https://newco.example.com', kind: 'competitor', why: 'Named as the rival in the launch post.' }],
70
+ };
71
+
72
+ function fakeClient(respond = () => reply(GOOD)) {
73
+ const calls = [];
74
+ return {
75
+ calls,
76
+ client: { messages: { create: async (req) => { calls.push(req); return respond(req, calls.length); } } },
77
+ };
78
+ }
79
+
80
+ // --- an in-memory store with store.js's contract ----------------------------
81
+
82
+ function memStore({ items, settings, runs = [], runItems = [], spendThrows = false, spendThrowsAfter = Infinity, rules } = {}) {
83
+ const s = {
84
+ items: items || built.items.slice(0, 4).map((it, i) => ({ id: i + 1, project: P, kind: it.kind, name: it.name, url: it.url, description: it.description, why_tracked: it.whyTracked, status: 'active' })),
85
+ settings: settings === undefined ? { enabled: true, monthly_cap_usd: 20, cadence: 'weekly' } : settings,
86
+ runs: runs.map((r) => ({ ...r })),
87
+ runItems: new Map(runItems.map((r) => [`${r.run_id}:${r.item_id}`, { ...r }])),
88
+ entries: [], scores: [], proposals: [], newItems: [],
89
+ spendReads: 0,
90
+ rules: rules === undefined ? { id: 1, ...built.rules, search_queries: built.rules.searchQueries } : rules,
91
+ };
92
+ let nextRun = 100;
93
+ const api = {
94
+ state: s,
95
+ async readSettings(project) { assert.equal(project, P); return s.settings ? { ...s.settings } : { enabled: false, monthly_cap_usd: 20, cadence: 'weekly' }; },
96
+ async lastRun(project) { const r = s.runs.filter((x) => x.project === project).sort((a, b) => b.created_at - a.created_at)[0]; return r || null; },
97
+ async openRun(project) {
98
+ const r = s.runs.find((x) => x.project === project && ['queued', 'running'].includes(x.status));
99
+ return r ? { ...r, leased: r.claimed_until > NOW } : null;
100
+ },
101
+ async insertSkipped(project, o) { const id = nextRun++; s.runs.push({ id, project, status: 'skipped', ...o, created_at: NOW }); return id; },
102
+ async createRun(project, o) {
103
+ if (s.runs.some((x) => x.project === project && ['queued', 'running'].includes(x.status))) return null;
104
+ const id = nextRun++;
105
+ s.runs.push({ id, project, status: 'running', ...o, claimed_until: NOW + o.leaseMs, created_at: NOW });
106
+ return id;
107
+ },
108
+ async claimRun(project, id, leaseMs) {
109
+ const r = s.runs.find((x) => x.id === id && x.project === project);
110
+ if (!r || r.claimed_until > NOW) return false;
111
+ r.claimed_until = NOW + leaseMs; r.status = 'running'; return true;
112
+ },
113
+ async renewLease(project, id) { return s.runs.some((x) => x.id === id && x.project === project && x.status === 'running'); },
114
+ async finishRun(project, id, { status, reason }) { const r = s.runs.find((x) => x.id === id && x.project === project); Object.assign(r, { status, reason }); return true; },
115
+ async startItem(project, runId, itemId) {
116
+ const k = `${runId}:${itemId}`;
117
+ const row = s.runItems.get(k);
118
+ if (row && ['done', 'error'].includes(row.status)) return false;
119
+ s.runItems.set(k, { run_id: runId, item_id: itemId, status: 'running' });
120
+ return true;
121
+ },
122
+ async recordItem(project, runId, itemId, r) {
123
+ const item = s.items.find((i) => i.id === itemId);
124
+ const runRow = s.runs.find((x) => x.id === runId);
125
+ if (!item || item.project !== project || !runRow || runRow.project !== project) {
126
+ const e = new Error('cross_project'); e.code = 'cross_project'; throw e;
127
+ }
128
+ const row = s.runItems.get(`${runId}:${itemId}`);
129
+ if (row && ['done', 'error'].includes(row.status)) return { recorded: false, counts: { entries: 0, scores: 0, proposals: 0 } };
130
+ const counts = { entries: 0, scores: 0, proposals: 0 };
131
+ if (r.ok) {
132
+ for (const e of r.entries) { assert.match(e.sourceUrl, /^https?:\/\//, 'never an entry without a source'); s.entries.push({ item_id: itemId, run_id: runId, ...e }); counts.entries += 1; }
133
+ for (const sc of r.scores) { s.scores.push({ item_id: itemId, run_id: runId, ...sc }); counts.scores += 1; }
134
+ for (const c of r.candidates) { s.proposals.push({ project, run_id: runId, kind: 'add_item', origin: 'run', payload: c }); counts.proposals += 1; }
135
+ }
136
+ s.runItems.set(`${runId}:${itemId}`, { run_id: runId, item_id: itemId, status: r.ok ? 'done' : 'error', error: r.ok ? null : r.error, cost_usd: r.costUsd });
137
+ return { recorded: true, counts };
138
+ },
139
+ async remainingItems(project, runId) {
140
+ return s.items.filter((i) => i.status === 'active' && !['done', 'error'].includes((s.runItems.get(`${runId}:${i.id}`) || {}).status));
141
+ },
142
+ async monthSpend() {
143
+ s.spendReads += 1;
144
+ if (spendThrows || s.spendReads > spendThrowsAfter) throw new Error('db down');
145
+ return [...s.runItems.values()].reduce((a, r) => a + (Number(r.cost_usd) || 0), 0);
146
+ },
147
+ async activeRules() { return s.rules; },
148
+ };
149
+ return api;
150
+ }
151
+
152
+ function fakeEconomy() {
153
+ const rows = new Map();
154
+ const calls = [];
155
+ return {
156
+ calls, rows,
157
+ async logCost(row) { calls.push(row); const k = `${row.source}|${row.sourceRef}`; if (!rows.has(k)) rows.set(k, row); },
158
+ };
159
+ }
160
+
161
+ function deps(over = {}) {
162
+ const fc = over.fake || fakeClient();
163
+ const keysSeen = [];
164
+ return {
165
+ fc,
166
+ keysSeen,
167
+ d: {
168
+ project: P,
169
+ now: () => NOW,
170
+ readKey: async () => KEY,
171
+ createClient: (k) => { keysSeen.push(k); return fc.client; },
172
+ economy: fakeEconomy(),
173
+ warn: () => {},
174
+ ...over.d,
175
+ },
176
+ };
177
+ }
178
+
179
+ // --- the request -----------------------------------------------------------
180
+
181
+ test('the request: Sonnet 5, web search capped at 3 and fetch at 2, rules cached first, a JSON reply', () => {
182
+ const req = research.buildRequest({ item: { name: 'Acme', kind: 'competitor', url: 'https://acme.test' }, rules: { ...built.rules, search_queries: built.rules.searchQueries }, today: '2026-11-20' });
183
+ assert.equal(req.model, 'claude-sonnet-5');
184
+ const search = req.tools.find((t) => t.name === 'web_search');
185
+ const fetch = req.tools.find((t) => t.name === 'web_fetch');
186
+ assert.deepEqual([search.type, search.max_uses], ['web_search_20260209', 3]);
187
+ assert.deepEqual([fetch.type, fetch.max_uses, fetch.max_content_tokens], ['web_fetch_20260209', 2, 10_000]);
188
+ assert.equal(req.tools.length, 2, 'no other tool: dynamic filtering brings its own code execution');
189
+ assert.ok(req.max_tokens > 0 && req.max_tokens <= 8_000, 'bounded output');
190
+ assert.deepEqual(req.system[0].cache_control, { type: 'ephemeral' });
191
+ assert.match(req.system[0].text, /Every finding needs a source link/, 'the approved rules are the cached prefix');
192
+ assert.doesNotMatch(req.system[0].text, /2026-11-20|Acme/, 'nothing per-item or per-day in the cached prefix');
193
+ assert.match(req.messages[0].content, /Acme/);
194
+ assert.equal(req.output_config.format.type, 'json_schema');
195
+ assert.equal(req.output_config.format.schema, research.REPLY_SCHEMA);
196
+ });
197
+
198
+ test('the run calls the API on the PROJECT key it read, and only that key', async () => {
199
+ const { d, fc, keysSeen } = deps();
200
+ const r = await run.runDue({ ...d, store: memStore() });
201
+ assert.equal(r.outcome, 'done');
202
+ assert.deepEqual(keysSeen, [KEY], 'one client, built on the project key');
203
+ assert.equal(fc.calls.length, 4, 'one call per tracked item');
204
+ assert.ok(fc.calls.every((c) => c.model === 'claude-sonnet-5'));
205
+ });
206
+
207
+ test('the key accessor decrypts inside a run only, and a blob it cannot open is no key', async () => {
208
+ const pool = (enc) => ({ query: async (sql, params) => { assert.equal(sql, key.KEY_SQL); assert.equal(params[0], P); return { rows: enc === undefined ? [] : [{ anthropic_key_enc: enc }] }; } });
209
+ assert.equal(await key.readProjectKey(pool('v1.a.b.c'), P, { decrypt: () => KEY }), KEY);
210
+ assert.equal(await key.readProjectKey(pool(undefined), P, { decrypt: () => KEY }), null);
211
+ assert.equal(await key.readProjectKey(pool(null), P, { decrypt: () => KEY }), null);
212
+ assert.equal(await key.readProjectKey(pool('v1.a.b.c'), P, { decrypt: () => { throw new Error('bad tag'); } }), null);
213
+ const sealed = key.sealProjectKey(` ${KEY} `, { encrypt: (s) => `v1.sealed(${s.length})` });
214
+ assert.deepEqual(sealed, { key_enc: `v1.sealed(${KEY.length})`, last4: 'ABCD' });
215
+ assert.equal(key.sealProjectKey('short', { encrypt: () => 'x' }).error, 'key_wrong_length');
216
+ const spaced = key.sealProjectKey('has a space in it here', { encrypt: () => 'x' });
217
+ assert.deepEqual(spaced, { error: 'key_has_spaces' }, 'refused, and the key is never echoed back');
218
+ });
219
+
220
+ // --- the reply -------------------------------------------------------------
221
+
222
+ test('parsing: only entries sourced from THIS response survive, scores stay on the rubric and in 0..10', () => {
223
+ const seen = research.seenUrls(reply(GOOD).content);
224
+ const p = research.parseReply(JSON.stringify(GOOD), { seen, dimensions: ['human_gates', 'open_source_ethos'] });
225
+ assert.equal(p.ok, true);
226
+ assert.deepEqual(p.entries.map((e) => e.sourceUrl), [NEWS_URL, `${FETCHED_URL}/`], 'the invented link is dropped');
227
+ assert.deepEqual(p.entries.map((e) => e.confidence), [3, 2]);
228
+ assert.equal(p.entries[1].sourceLabel, 'blog.example.com', 'an empty label falls back to the host');
229
+ assert.deepEqual(p.scores.map((s) => [s.dimension, s.value]), [['overall', 6.2], ['human_gates', 7]]);
230
+ assert.deepEqual(p.dropped, { entries: 1, scores: 2, candidates: 0 });
231
+ assert.equal(p.candidates[0].name, 'Newco Agents');
232
+ });
233
+
234
+ test('parsing: a malformed reply is an error, never a partial guess', () => {
235
+ const seen = new Set([NEWS_URL]);
236
+ for (const bad of ['', 'not json', '{"entries": [}', '[]', '{"entries": []}', JSON.stringify({ entries: 'x', scores: [], candidates: [] }), 'null']) {
237
+ assert.deepEqual(research.parseReply(bad, { seen }), { ok: false, error: 'malformed_reply' }, `input: ${bad}`);
238
+ }
239
+ const fenced = '```json\n' + JSON.stringify({ entries: [], scores: [], candidates: [] }) + '\n```';
240
+ assert.equal(research.parseReply(fenced, { seen }).ok, true, 'a fenced object is still the object');
241
+ const noSource = { entries: [{ type: 'news', takeaway: 'x', source_url: '', source_label: '', confidence: 'high', dimension: null }], scores: [], candidates: [] };
242
+ assert.equal(research.parseReply(JSON.stringify(noSource), { seen }).entries.length, 0, 'no source, no entry');
243
+ });
244
+
245
+ test('a refusal, a max_tokens or pause_turn stop, a tool error or a thrown call ends the item, costed', async () => {
246
+ const item = { name: 'Acme', kind: 'competitor' };
247
+ const rules = built.rules;
248
+ for (const stop of ['refusal', 'max_tokens', 'pause_turn']) {
249
+ const { client } = fakeClient(() => reply(GOOD, { stop }));
250
+ const r = await research.researchItem({ client, item, rules, today: '2026-11-20' });
251
+ assert.deepEqual([r.ok, r.error, r.costUsd], [false, stop, REPLY_COST]);
252
+ }
253
+ const toolErr = { type: 'web_fetch_tool_result', tool_use_id: 'f2', content: { type: 'web_fetch_tool_result_error', error_code: 'url_not_accessible' } };
254
+ let r = await research.researchItem({ client: fakeClient(() => reply(GOOD, { extra: [toolErr] })).client, item, rules, today: 'x' });
255
+ assert.deepEqual([r.ok, r.error], [false, 'tool_error:url_not_accessible']);
256
+ const capped = { type: 'web_search_tool_result', tool_use_id: 's9', content: { type: 'web_search_tool_result_error', error_code: 'max_uses_exceeded' } };
257
+ r = await research.researchItem({ client: fakeClient(() => reply(GOOD, { extra: [capped] })).client, item, rules, today: 'x' });
258
+ assert.equal(r.ok, true, 'the search cap refusing a 4th search is the cap working, not a failure');
259
+ r = await research.researchItem({ client: fakeClient(() => reply('I could not find anything.')).client, item, rules, today: 'x' });
260
+ assert.deepEqual([r.ok, r.error, r.costUsd], [false, 'malformed_reply', REPLY_COST]);
261
+ r = await research.researchItem({ client: { messages: { create: async () => { const e = new Error('overloaded'); e.status = 529; throw e; } } }, item, rules, today: 'x' });
262
+ assert.deepEqual([r.ok, r.error, r.costUsd], [false, 'api_error 529', 0]);
263
+ });
264
+
265
+ test('cost: tokens at the Sonnet 5 rate plus $0.01 a search, and an item ceiling that covers the bounds', () => {
266
+ assert.equal(WEB_SEARCH_USD_PER_REQUEST, 0.01);
267
+ assert.equal(priceWebSearches(3), 0.03);
268
+ assert.equal(priceWebSearches(undefined), 0);
269
+ assert.equal(research.priceResponse(reply(GOOD).usage), REPLY_COST);
270
+ assert.equal(research.priceResponse({ input_tokens: 1000, cache_read_input_tokens: 3000, output_tokens: 0 }), 0.0026);
271
+ const ceiling = research.itemCeilingUsd();
272
+ assert.ok(Math.abs(ceiling - 0.27) < 1e-9, `ceiling ${ceiling}`);
273
+ const worst = research.priceResponse({ input_tokens: 80_000, output_tokens: 8_000, server_tool_use: { web_search_requests: 3 } });
274
+ assert.ok(worst <= ceiling + 1e-9, 'a response at every bound fits in the ceiling');
275
+ });
276
+
277
+ // --- a run on seed data ----------------------------------------------------
278
+
279
+ test('a run on seed data writes sourced entries, score snapshots and proposals, and mirrors each item cost', async () => {
280
+ const { d } = deps();
281
+ const st = memStore();
282
+ const r = await run.runDue({ ...d, store: st });
283
+ assert.equal(r.outcome, 'done');
284
+ assert.equal(r.done, 4);
285
+ assert.equal(st.state.entries.length, 8, 'two sourced entries per item; the invented one never lands');
286
+ assert.ok(st.state.entries.every((e) => e.sourceUrl === NEWS_URL || e.sourceUrl === `${FETCHED_URL}/`));
287
+ assert.equal(st.state.scores.length, 8);
288
+ assert.ok(st.state.scores.every((s) => s.reason));
289
+ const runId = r.runId;
290
+ assert.equal(st.state.runs.find((x) => x.id === runId).status, 'done');
291
+ // The cost roll-up: one cost_log row per item, in-process, under market-watch:<run id>.
292
+ const rows = [...d.economy.rows.values()];
293
+ assert.equal(rows.length, 4);
294
+ for (const row of rows) {
295
+ assert.equal(row.source, `market-watch:${runId}`);
296
+ assert.match(row.sourceRef, /^item-\d+$/);
297
+ assert.equal(row.category, 'api');
298
+ assert.equal(row.amountUsd, REPLY_COST);
299
+ assert.equal(row.builderId, null, 'the project pays, not a builder');
300
+ assert.ok(row.description.includes(P), 'recorded against the project');
301
+ }
302
+ assert.ok(Math.abs(r.costUsd - 4 * REPLY_COST) < 1e-9);
303
+ });
304
+
305
+ test('a new candidate becomes a proposal, never a tracked item', async () => {
306
+ const { d } = deps();
307
+ const st = memStore();
308
+ const before = st.state.items.length;
309
+ await run.runDue({ ...d, store: st });
310
+ assert.equal(st.state.items.length, before, 'no item added');
311
+ assert.ok(st.state.proposals.length >= 1);
312
+ assert.ok(st.state.proposals.every((p) => p.kind === 'add_item' && p.origin === 'run' && p.project === P));
313
+ // and the SQL that writes it targets proposals only, scoped to the project
314
+ assert.match(store.SQL.PROPOSAL_SQL, /INSERT INTO market_watch_proposals/);
315
+ assert.doesNotMatch(store.SQL.PROPOSAL_SQL, /INSERT INTO market_watch_items/);
316
+ });
317
+
318
+ // --- nothing runs, and the row says why ---------------------------------------
319
+
320
+ test('Market Watch off: nothing runs, the run row says off', async () => {
321
+ const { d, fc, keysSeen } = deps();
322
+ const st = memStore({ settings: { enabled: false, monthly_cap_usd: 20, cadence: 'weekly' } });
323
+ const r = await run.runDue({ ...d, store: st });
324
+ assert.deepEqual([r.outcome, r.reason], ['skipped', 'off']);
325
+ assert.equal(st.state.runs.at(-1).status, 'skipped');
326
+ assert.equal(st.state.runs.at(-1).reason, 'off');
327
+ assert.equal(fc.calls.length + keysSeen.length, 0);
328
+ });
329
+
330
+ test('no key: nothing runs, the run row says no_key', async () => {
331
+ const { d, fc, keysSeen } = deps({ d: { readKey: async () => null } });
332
+ const st = memStore();
333
+ const r = await run.runDue({ ...d, store: st });
334
+ assert.deepEqual([r.outcome, r.reason], ['skipped', 'no_key']);
335
+ assert.deepEqual([st.state.runs.at(-1).status, st.state.runs.at(-1).reason], ['skipped', 'no_key']);
336
+ assert.equal(fc.calls.length + keysSeen.length, 0);
337
+ });
338
+
339
+ // --- the cap ---------------------------------------------------------------
340
+
341
+ test('over the cap before the run: skipped and visible, with the cap and the spend recorded', async () => {
342
+ const { d, fc } = deps();
343
+ const st = memStore({ settings: { enabled: true, monthly_cap_usd: 5, cadence: 'weekly' },
344
+ runs: [{ id: 1, project: P, status: 'done', created_at: NOW - 10 * DAY }],
345
+ runItems: [{ run_id: 1, item_id: 1, status: 'done', cost_usd: 4.8 }] });
346
+ const r = await run.runDue({ ...d, store: st });
347
+ assert.deepEqual([r.outcome, r.reason], ['skipped', 'cap_reached']);
348
+ const row = st.state.runs.at(-1);
349
+ assert.deepEqual([row.status, row.reason, row.capUsd, row.spentBeforeUsd], ['skipped', 'cap_reached', 5, 4.8]);
350
+ assert.equal(fc.calls.length, 0);
351
+ });
352
+
353
+ test('the cap is checked before EVERY item: a run stops mid-way, partial, and never passes the cap', async () => {
354
+ const { d, fc } = deps();
355
+ // $0.70 cap, $0.27 ceiling, $0.075 an item: items 1..6 fit (0.375 + 0.27 = 0.645), the 7th would not.
356
+ const items = built.items.slice(0, 10).map((it, i) => ({ id: i + 1, project: P, kind: it.kind, name: it.name, url: it.url, status: 'active' }));
357
+ const st = memStore({ items, settings: { enabled: true, monthly_cap_usd: 0.7, cadence: 'weekly' } });
358
+ const r = await run.runDue({ ...d, store: st });
359
+ assert.deepEqual([r.outcome, r.reason, r.done], ['partial', 'cap_reached', 6]);
360
+ assert.equal(fc.calls.length, 6, 'no call after the cap check refused');
361
+ assert.equal(st.state.runs.at(-1).status, 'partial');
362
+ const spent = [...st.state.runItems.values()].reduce((a, x) => a + x.cost_usd, 0);
363
+ assert.ok(spent <= 0.7);
364
+ assert.ok(st.state.spendReads >= 7, 'the spend was re-read before each item');
365
+ });
366
+
367
+ test('a cap of zero, or one that is not a number, allows nothing (fail closed)', async () => {
368
+ for (const cap of [0, -1, NaN, 'lots']) {
369
+ const { d, fc } = deps();
370
+ const st = memStore({ settings: { enabled: true, monthly_cap_usd: cap, cadence: 'weekly' } });
371
+ const r = await run.runDue({ ...d, store: st });
372
+ assert.equal(r.reason, 'cap_reached', `cap ${cap}`);
373
+ assert.equal(fc.calls.length, 0);
374
+ }
375
+ });
376
+
377
+ test('an unreadable spend runs nothing: before the run it is a skipped row, mid-run it stops the run', async () => {
378
+ let { d, fc } = deps();
379
+ let st = memStore({ spendThrows: true });
380
+ let r = await run.runDue({ ...d, store: st });
381
+ assert.deepEqual([r.outcome, r.reason, fc.calls.length], ['skipped', 'spend_unreadable', 0]);
382
+ ({ d, fc } = deps());
383
+ st = memStore({ spendThrowsAfter: 3 }); // the precheck read, then items 1 and 2
384
+ r = await run.runDue({ ...d, store: st });
385
+ assert.deepEqual([r.outcome, r.reason, r.done, fc.calls.length], ['partial', 'spend_unreadable', 2, 2]);
386
+ });
387
+
388
+ // --- resume after a cut-off --------------------------------------------------
389
+
390
+ test('a cut-off run resumes only the items it has not finished, and never mirrors a cost twice', async () => {
391
+ const { d, fc } = deps();
392
+ const st = memStore({
393
+ runs: [{ id: 7, project: P, status: 'running', claimed_until: NOW - 60_000, created_at: NOW - 2 * 3600_000 }],
394
+ runItems: [
395
+ { run_id: 7, item_id: 1, status: 'done', cost_usd: 0.09 },
396
+ { run_id: 7, item_id: 2, status: 'error', error: 'refusal', cost_usd: 0.02 },
397
+ { run_id: 7, item_id: 3, status: 'running' }, // the call in flight when the deploy hit
398
+ ],
399
+ });
400
+ // The first run's mirrors are already in cost_log.
401
+ await d.economy.logCost({ source: 'market-watch:7', sourceRef: 'item-1', amountUsd: 0.09 });
402
+ d.economy.calls.length = 0;
403
+ const r = await run.runDue({ ...d, store: st });
404
+ assert.equal(r.resumed, true);
405
+ assert.equal(r.runId, 7);
406
+ assert.equal(fc.calls.length, 2, 'items 3 and 4 only');
407
+ assert.deepEqual(d.economy.calls.map((c) => c.sourceRef).sort(), ['item-3', 'item-4']);
408
+ assert.equal(st.state.runs[0].status, 'done');
409
+ // A second write of an already-recorded item is refused by the store, so nothing is mirrored.
410
+ const again = await st.recordItem(P, 7, 3, { ok: true, entries: [], scores: [], candidates: [], costUsd: 0.5 });
411
+ assert.equal(again.recorded, false);
412
+ });
413
+
414
+ test('a run still leased by another process is left alone (a lapsed lease is the resume case above)', async () => {
415
+ const { d, fc } = deps();
416
+ const st = memStore({ runs: [{ id: 9, project: P, status: 'running', claimed_until: NOW + 60_000, created_at: NOW - 600_000 }] });
417
+ assert.deepEqual(await run.runDue({ ...d, store: st }), { skipped: 'in_flight', runId: 9 });
418
+ assert.equal(fc.calls.length, 0);
419
+ });
420
+
421
+ test('due by the ledger: a week after the last run (minus 30 minutes), never by the boot', () => {
422
+ assert.equal(run.isDue(null, 'weekly', NOW), true, 'no run yet');
423
+ assert.equal(run.isDue({ created_at: NOW - 3 * DAY }, 'weekly', NOW), false);
424
+ assert.equal(run.isDue({ created_at: NOW - 7 * DAY + 20 * 60_000 }, 'weekly', NOW), true);
425
+ assert.equal(run.isDue({ created_at: NOW - 7 * DAY + 40 * 60_000 }, 'weekly', NOW), false);
426
+ assert.equal(run.isDue({ created_at: NOW - 10 * DAY }, 'biweekly', NOW), false);
427
+ });
428
+
429
+ test('a run that is not due does nothing at all', async () => {
430
+ const { d, fc } = deps();
431
+ const st = memStore({ runs: [{ id: 1, project: P, status: 'done', created_at: NOW - 2 * DAY }] });
432
+ assert.deepEqual(await run.runDue({ ...d, store: st }), { skipped: 'not_due' });
433
+ assert.equal(st.state.runs.length, 1, 'not even a skipped row');
434
+ assert.equal(fc.calls.length, 0);
435
+ });
436
+
437
+ // --- the cross-project guard -------------------------------------------------
438
+
439
+ test('a run never researches or writes another project\'s item', async () => {
440
+ const { d, fc } = deps();
441
+ const st = memStore();
442
+ st.state.items[1].project = OTHER; // a read that leaked a foreign row
443
+ const r = await run.runDue({ ...d, store: st });
444
+ assert.equal(r.refused, 1);
445
+ assert.equal(fc.calls.length, 3, 'no spend on the foreign item');
446
+ assert.ok(!st.state.entries.some((e) => e.item_id === 2));
447
+ assert.ok(![...d.economy.rows.values()].some((x) => x.sourceRef === 'item-2'));
448
+ // and the store itself refuses it, even if the caller did not
449
+ await assert.rejects(st.recordItem(P, r.runId, 2, { ok: true, entries: [], scores: [], candidates: [], costUsd: 0 }), /cross_project/);
450
+ });
451
+
452
+ test('store.js: the guard is in the SQL, and a foreign item writes nothing', async () => {
453
+ const calls = [];
454
+ const client = {
455
+ async query(sql, params) { calls.push({ sql, params }); return /FROM market_watch_items i\s+JOIN market_watch_runs/.test(sql) ? { rows: [] } : { rows: [], rowCount: 0 }; },
456
+ release() {},
457
+ };
458
+ const pool = { connect: async () => client };
459
+ await assert.rejects(store.recordItem(pool, P, 5, 2, { ok: true, entries: [{ type: 'news', takeaway: 't', sourceUrl: NEWS_URL, sourceLabel: '', confidence: 2, dimension: null }], scores: [], candidates: [], costUsd: 0.1 }), /cross_project/);
460
+ assert.ok(!calls.some((c) => /INSERT/.test(c.sql)), 'nothing inserted');
461
+ assert.ok(calls.some((c) => c.sql === 'ROLLBACK'));
462
+ for (const sql of [store.SQL.GUARD_SQL, store.SQL.ENTRY_SQL, store.SQL.SCORE_SQL, store.SQL.START_ITEM_SQL]) {
463
+ assert.match(sql, /\.project = \$1/, `scoped to the project: ${sql.trim().slice(0, 50)}`);
464
+ }
465
+ assert.match(store.SQL.PROPOSAL_SQL, /SELECT \$1::text, 'add_item', 'run'/);
466
+ });
467
+
468
+ test('store.js: an item result is recorded once; a second write rolls back and reports it', async () => {
469
+ const calls = [];
470
+ const client = {
471
+ async query(sql, params) {
472
+ calls.push(sql);
473
+ if (/JOIN market_watch_runs r ON r.project = i.project/.test(sql)) return { rows: [{ '?column?': 1 }] };
474
+ if (/UPDATE market_watch_run_items/.test(sql)) return { rows: [] }; // already finished
475
+ return { rows: [], rowCount: 1 };
476
+ },
477
+ release() {},
478
+ };
479
+ const r = await store.recordItem({ connect: async () => client }, P, 5, 2, { ok: false, error: 'refusal', costUsd: 0.02 });
480
+ assert.equal(r.recorded, false);
481
+ assert.equal(calls.at(-1), 'ROLLBACK');
482
+ });
483
+
484
+ // --- the poller --------------------------------------------------------------
485
+
486
+ test('the poller tick never throws, and is quiet on a machine with no database', async () => {
487
+ const boom = { openRun: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:5432'); } };
488
+ assert.deepEqual(await weekly.tick({ project: P, store: boom, readKey: async () => KEY }), { skipped: 'db_unreachable' });
489
+ const bad = { openRun: async () => { throw new Error('syntax error'); } };
490
+ let reported = 0;
491
+ const r = await weekly.tick({ project: P, store: bad, readKey: async () => KEY, reportError: () => { reported += 1; } });
492
+ assert.equal(r.skipped, 'error');
493
+ assert.equal(reported, 1);
494
+ assert.deepEqual(await weekly.tick({ project: null }), { skipped: 'no_project_origin' });
495
+ });
496
+
497
+ test('the migration widens proposals for the run, adds the key columns, and one open run per project', () => {
498
+ const sql = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
499
+ assert.match(sql, /CHECK \(origin IN \('member', 'weekly_ask', 'run'\)\)/);
500
+ assert.match(sql, /anthropic_key_enc LIKE 'v1\.%'/);
501
+ assert.match(sql, /market_watch_runs_one_open\s+ON market_watch_runs \(project\) WHERE status IN \('queued', 'running'\)/);
502
+ assert.match(store.SQL.CREATE_SQL, /ON CONFLICT \(project\) WHERE status IN \('queued', 'running'\) DO NOTHING/);
503
+ });