@bongos/core 1.23.22 → 1.24.2

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 (435) hide show
  1. package/.bongos-core.json +933 -413
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +2 -2
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +96 -0
  27. package/clients/bongos-client/index.cjs +96 -0
  28. package/clients/bongos-client/index.d.ts +155 -4
  29. package/clients/bongos-client/index.mjs +96 -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 +12 -1
  33. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  34. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  35. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  36. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  37. package/docs/adr/README.md +6 -2
  38. package/docs/api/openapi.json +8651 -5833
  39. package/docs/api-reference.md +66 -10
  40. package/docs/architecture.md +37 -1
  41. package/docs/canonical-permissions.md +1 -1
  42. package/docs/copy-inventory.md +427 -339
  43. package/docs/copy-registry.json +1376 -500
  44. package/docs/file-map.md +1 -0
  45. package/docs/module-api-changelog.md +7 -0
  46. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  47. package/docs/onboarding/diagrams/README.md +1 -1
  48. package/docs/onboarding/drachmae-and-karma.md +15 -15
  49. package/docs/onboarding/primer.md +2 -2
  50. package/docs/page-inventory.json +57 -7
  51. package/docs/page-readings.json +1210 -1027
  52. package/docs/recipes/google-sign-in.md +60 -0
  53. package/docs/recipes/ops-gotchas.md +1 -1
  54. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  55. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  56. package/migrations/core_283_payout_approvals.sql +183 -0
  57. package/migrations/core_284_payout_gates.sql +179 -0
  58. package/migrations/core_285_task_needs.sql +74 -0
  59. package/modules/ai-studio/billing.js +67 -0
  60. package/modules/ai-studio/context.js +29 -0
  61. package/modules/ai-studio/db.js +172 -0
  62. package/modules/ai-studio/gemini.js +75 -0
  63. package/modules/ai-studio/generate.js +119 -0
  64. package/modules/ai-studio/keys.js +73 -0
  65. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  66. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  67. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  68. package/modules/ai-studio/openrouter.js +90 -0
  69. package/modules/ai-studio/payments.js +105 -0
  70. package/modules/ai-studio/pricing.js +92 -0
  71. package/modules/ai-studio/routes/ai-studio.js +235 -5
  72. package/modules/ai-studio/studio.js +121 -0
  73. package/modules/autonomy/fence.js +64 -1
  74. package/modules/builder-settings/builder-needs.js +27 -1
  75. package/modules/copy-desk/routes/copy-desk.js +5 -0
  76. package/modules/economy/achievements.js +22 -10
  77. package/modules/economy/cost-classify.js +9 -0
  78. package/modules/economy/cost-write-guard.js +52 -0
  79. package/modules/economy/cost.js +2 -1
  80. package/modules/economy/credits.js +18 -15
  81. package/modules/economy/goal-reward.js +161 -0
  82. package/modules/economy/reward.js +4 -0
  83. package/modules/economy/routes/cost.js +33 -7
  84. package/modules/government/access-routes.js +70 -0
  85. package/modules/government/access.js +88 -0
  86. package/modules/government/asks.js +5 -2
  87. package/modules/government/catalog.js +6 -0
  88. package/modules/government/db.js +72 -0
  89. package/modules/government/docket.js +48 -2
  90. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  91. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  92. package/modules/government/pollers/board-expiry.js +108 -20
  93. package/modules/government/protected-surfaces.json +1 -0
  94. package/modules/government/routes/government.js +31 -0
  95. package/modules/government/routing.js +49 -7
  96. package/modules/government/waiting.js +78 -0
  97. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  98. package/modules/hall-ui/public/ai-studio.css +193 -0
  99. package/modules/hall-ui/public/ai-studio.html +98 -0
  100. package/modules/hall-ui/public/ai-studio.js +556 -0
  101. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  102. package/modules/hall-ui/public/blockers-by-person.js +158 -0
  103. package/modules/hall-ui/public/blockers-lib.js +15 -1
  104. package/modules/hall-ui/public/blockers.html +8 -0
  105. package/modules/hall-ui/public/blockers.js +1 -1
  106. package/modules/hall-ui/public/claim-action.js +3 -0
  107. package/modules/hall-ui/public/collab.html +12 -0
  108. package/modules/hall-ui/public/craft-mode.js +4 -1
  109. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  110. package/modules/hall-ui/public/drachmae.html +1 -1
  111. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  112. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  113. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  114. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  115. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  120. package/modules/hall-ui/public/goal-inbox.js +1 -1
  121. package/modules/hall-ui/public/goals-render.js +4 -0
  122. package/modules/hall-ui/public/goals.html +8 -0
  123. package/modules/hall-ui/public/gov-living.js +6 -2
  124. package/modules/hall-ui/public/government-access.js +108 -0
  125. package/modules/hall-ui/public/government.html +21 -0
  126. package/modules/hall-ui/public/hall-ask.css +5 -0
  127. package/modules/hall-ui/public/hall-ask.js +39 -3
  128. package/modules/hall-ui/public/notes-panel.css +20 -0
  129. package/modules/hall-ui/public/notes-panel.js +146 -0
  130. package/modules/hall-ui/public/project-settings.html +1 -1
  131. package/modules/hall-ui/public/project-settings.js +7 -5
  132. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  133. package/modules/hall-ui/public/settings.html +15 -0
  134. package/modules/hall-ui/public/street-deck.js +12 -3
  135. package/modules/hall-ui/public/studio.css +90 -8
  136. package/modules/hall-ui/public/studio.html +39 -10
  137. package/modules/hall-ui/public/studio.js +148 -18
  138. package/modules/hall-ui/public/task.html +2 -0
  139. package/modules/hall-ui/public/task.js +4 -0
  140. package/modules/hall-ui/public/tweak-editor.js +12 -3
  141. package/modules/hall-ui/public/welcome-boot.css +42 -6
  142. package/modules/hall-ui/public/welcome-boot.js +20 -6
  143. package/modules/hall-ui/public/work.js +20 -0
  144. package/modules/hall-ui/records/ai-studio.md +36 -0
  145. package/modules/hall-ui/records/blockers-people.md +38 -0
  146. package/modules/lifecycle/db-claims.js +32 -4
  147. package/modules/lifecycle/db-goals.js +18 -5
  148. package/modules/lifecycle/db-tasks.js +15 -6
  149. package/modules/lifecycle/done-when.js +23 -1
  150. package/modules/lifecycle/lifecycle.js +5 -0
  151. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  152. package/modules/lifecycle/module.json +2 -1
  153. package/modules/lifecycle/notes.js +219 -0
  154. package/modules/lifecycle/page-notice.js +124 -0
  155. package/modules/lifecycle/page-tweak-artist.js +18 -1
  156. package/modules/lifecycle/publish-reconciler.js +67 -20
  157. package/modules/lifecycle/routes/claims.js +5 -2
  158. package/modules/lifecycle/routes/goals.js +3 -1
  159. package/modules/lifecycle/routes/notes.js +91 -0
  160. package/modules/lifecycle/routes/project-settings.js +43 -35
  161. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  162. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  163. package/modules/lifecycle/routes/tasks.js +12 -12
  164. package/modules/lifecycle/task-needs-gate.js +70 -0
  165. package/modules/market-watch/CLAUDE.md +15 -0
  166. package/modules/market-watch/db.js +340 -0
  167. package/modules/market-watch/key.js +58 -0
  168. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  169. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  170. package/modules/market-watch/module.json +26 -0
  171. package/modules/market-watch/pollers/hub-seed.js +54 -0
  172. package/modules/market-watch/pollers/weekly-run.js +100 -0
  173. package/modules/market-watch/research.js +350 -0
  174. package/modules/market-watch/run.js +245 -0
  175. package/modules/market-watch/seed/market-data.json +38 -0
  176. package/modules/market-watch/seed/partners-data.json +497 -0
  177. package/modules/market-watch/seed.js +252 -0
  178. package/modules/market-watch/store.js +230 -0
  179. package/modules/memory/routes/memory.js +14 -2
  180. package/modules/memory/routes/search.js +8 -2
  181. package/modules/platform-identity/account-logins.js +192 -0
  182. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  183. package/modules/platform-identity/platform-identity.js +5 -4
  184. package/modules/platform-identity/routes/my-projects.js +6 -0
  185. package/modules/platform-identity/routes/profile.js +18 -0
  186. package/modules/platform-identity/routes/public-profile.js +10 -1
  187. package/modules/platform-identity/routes/sso.js +26 -1
  188. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  189. package/modules/provisioning/credential-preflight.js +13 -3
  190. package/modules/provisioning/pollers/app-liveness.js +25 -5
  191. package/modules/provisioning/routes/provisioning.js +1 -1
  192. package/modules/provisioning/starter-bundles.js +6 -2
  193. package/modules/public-landing/public/account.html +10 -2
  194. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  195. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  196. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  197. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  198. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  199. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  200. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  201. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  202. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  203. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  205. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  209. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  210. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  213. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  214. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  221. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  222. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  223. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  224. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  225. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  226. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  227. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  228. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  229. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  230. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  233. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  244. package/modules/public-landing/public/community.html +1 -1
  245. package/modules/public-landing/public/connections.html +2 -2
  246. package/modules/public-landing/public/guild.html +1 -1
  247. package/modules/public-landing/public/invitations.html +2 -2
  248. package/modules/public-landing/public/projects.html +5 -5
  249. package/modules/raise/views/nda.html +2 -0
  250. package/modules/specialities/db.js +32 -1
  251. package/modules/specialities/routes/specialities.js +10 -6
  252. package/modules/status-ui/public/index.html +1 -1
  253. package/package-lock.json +2 -2
  254. package/package.json +1 -1
  255. package/release-notes.json +230 -0
  256. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  257. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  258. package/scripts/bongos/claim.js +22 -1
  259. package/scripts/bongos/db-backup-nightly.sh +29 -0
  260. package/scripts/bongos/deploy-divergence.js +7 -0
  261. package/scripts/bongos/doc-cli-guard.js +6 -0
  262. package/scripts/bongos/fitness.js +3 -2
  263. package/scripts/bongos/gen-diagrams.js +3 -3
  264. package/scripts/bongos/government-walk.js +3 -0
  265. package/scripts/bongos/lib.sh +4 -5
  266. package/scripts/bongos/migration-namespace.js +13 -1
  267. package/scripts/bongos/plain-cards.js +4 -0
  268. package/scripts/bongos/provision-github-setup.js +3 -1
  269. package/scripts/bongos/provision-units.js +6 -1
  270. package/scripts/bongos/publish-manifest.js +3 -0
  271. package/scripts/bongos/release.js +2 -0
  272. package/scripts/bongos/run-unit-tests.js +268 -82
  273. package/scripts/bongos/seed-tasks.example.json +56 -0
  274. package/scripts/bongos/seed-tasks.js +222 -0
  275. package/scripts/bongos/seed-verify-criteria.js +1 -2
  276. package/scripts/bongos/ship-finish.js +2 -0
  277. package/scripts/bongos/ship-resume.js +2 -0
  278. package/scripts/bongos/strand-lease.js +250 -0
  279. package/scripts/bongos/strand-watch.js +26 -5
  280. package/scripts/bongos/version-literal-guard.js +2 -2
  281. package/scripts/discord/post.js +4 -3
  282. package/scripts/gds/seed-tasks.js +7 -0
  283. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  284. package/scripts/hall-preview/server.js +21 -0
  285. package/src/bongos/auth-config.js +29 -1
  286. package/src/bongos/auth-discord.js +3 -3
  287. package/src/bongos/auth-github.js +3 -6
  288. package/src/bongos/auth-google.js +217 -0
  289. package/src/bongos/device-approval.js +140 -0
  290. package/src/bongos/llm-pricing.js +17 -1
  291. package/src/bongos/metrics.js +48 -2
  292. package/src/bongos/middleware/rate-limit.js +30 -11
  293. package/src/bongos/module-scope-map.js +12 -1
  294. package/src/bongos/optimizer.js +15 -1
  295. package/src/bongos/payout-approvals.js +342 -0
  296. package/src/bongos/payout-gates.js +364 -0
  297. package/src/bongos/prelaunch-gate.js +7 -9
  298. package/src/bongos/prelaunch-switch.js +20 -0
  299. package/src/bongos/route-rank-check.js +7 -0
  300. package/src/bongos/routes/auth-google.js +246 -0
  301. package/src/bongos/routes/auth.js +38 -1
  302. package/src/bongos/routes/backup.js +33 -9
  303. package/src/bongos/routes/builders.js +57 -30
  304. package/src/bongos/routes/github.js +9 -0
  305. package/src/bongos/routes/me.js +18 -0
  306. package/src/bongos/routes/my-payouts.js +133 -0
  307. package/src/bongos/routes/payout-approvals.js +312 -0
  308. package/src/bongos/routes/payout-gates.js +179 -0
  309. package/src/bongos/routes.js +15 -1
  310. package/src/bongos/secret-box.js +18 -3
  311. package/src/bongos/serve-internal.js +47 -19
  312. package/src/bongos/sign-in-pages.js +138 -0
  313. package/src/bongos/task-needs.js +146 -0
  314. package/src/bongos-downloads.js +18 -1
  315. package/src/instance-config.js +28 -0
  316. package/src/module-api.js +14 -1
  317. package/tests/access_holders.mjs +214 -0
  318. package/tests/account_logins.mjs +326 -0
  319. package/tests/account_logins_db.mjs +161 -0
  320. package/tests/ai_studio_billing.mjs +253 -0
  321. package/tests/ai_studio_generate.mjs +282 -0
  322. package/tests/ai_studio_keys.mjs +159 -0
  323. package/tests/ai_studio_requests.mjs +319 -0
  324. package/tests/auth_backchannel_logout_route.mjs +201 -0
  325. package/tests/auto_satisfy_criteria.mjs +10 -1
  326. package/tests/autobongos_tweak_lane.mjs +144 -0
  327. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  328. package/tests/backfill_subagent_rewards.mjs +17 -1
  329. package/tests/backup_trigger_lock.mjs +119 -0
  330. package/tests/blockers_people.mjs +198 -0
  331. package/tests/bounded_rate_maps.mjs +184 -0
  332. package/tests/claimable_blocker_status.mjs +1 -1
  333. package/tests/claims_batch_routes.mjs +421 -0
  334. package/tests/config_read_cache.mjs +171 -0
  335. package/tests/copy_desk_page_editor.mjs +12 -0
  336. package/tests/cost_write_guard.mjs +200 -0
  337. package/tests/craft_achievements.mjs +33 -10
  338. package/tests/criterion_goal_attach.mjs +1 -1
  339. package/tests/db_backup_nightly.mjs +73 -1
  340. package/tests/deploy_divergence_line.mjs +7 -0
  341. package/tests/effective_visibility_predicate.mjs +12 -0
  342. package/tests/fitness.mjs +19 -0
  343. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  344. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  345. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  346. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  347. package/tests/github_one_trip.mjs +34 -0
  348. package/tests/github_repo_visibility.mjs +20 -0
  349. package/tests/goal_achievement.mjs +30 -1
  350. package/tests/goal_achievement_reward.mjs +205 -0
  351. package/tests/goal_sort_staleness.mjs +2 -1
  352. package/tests/goal_tier.mjs +11 -1
  353. package/tests/google_fonts_nonblocking.mjs +84 -0
  354. package/tests/google_sign_in.mjs +401 -0
  355. package/tests/government_board_expiry.mjs +94 -0
  356. package/tests/government_board_membership.mjs +4 -1
  357. package/tests/government_protected_surfaces.mjs +1 -1
  358. package/tests/government_routes.mjs +1 -1
  359. package/tests/government_seed.mjs +1 -0
  360. package/tests/hall_ai_studio.mjs +347 -0
  361. package/tests/hall_page_gate_map.mjs +3 -0
  362. package/tests/hall_studio_home.mjs +61 -2
  363. package/tests/home_attention_cards.mjs +1 -1
  364. package/tests/human_signal.mjs +141 -0
  365. package/tests/install_script_reader.mjs +51 -0
  366. package/tests/integration_fixture_ids.mjs +82 -0
  367. package/tests/invite_expiry_db.mjs +13 -1
  368. package/tests/lib_sh_resolution.mjs +0 -4
  369. package/tests/market_watch_data.mjs +244 -0
  370. package/tests/market_watch_data_db.mjs +308 -0
  371. package/tests/market_watch_module.mjs +114 -0
  372. package/tests/market_watch_run.mjs +503 -0
  373. package/tests/metrics.mjs +124 -0
  374. package/tests/migration_namespace.mjs +14 -0
  375. package/tests/module_api.mjs +2 -0
  376. package/tests/module_loader.mjs +1 -1
  377. package/tests/notes.mjs +182 -0
  378. package/tests/own_scope_guard.mjs +5 -0
  379. package/tests/page_tweak_marker_sql.mjs +76 -0
  380. package/tests/page_tweak_no_block.mjs +135 -0
  381. package/tests/page_tweak_notice.mjs +103 -0
  382. package/tests/payout_approvals.mjs +253 -0
  383. package/tests/payout_approvals_db.mjs +106 -0
  384. package/tests/payout_gates.mjs +322 -0
  385. package/tests/payout_gates_db.mjs +113 -0
  386. package/tests/planning_session_skill.mjs +13 -3
  387. package/tests/project_settings_page.mjs +29 -5
  388. package/tests/project_settings_spend_cap_route.mjs +235 -0
  389. package/tests/promotion_sweep.mjs +29 -1
  390. package/tests/provision.mjs +3 -2
  391. package/tests/provisioning_app_status.mjs +18 -0
  392. package/tests/provisioning_credential_preflight.mjs +53 -0
  393. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  394. package/tests/publish_reconciler_overlap.mjs +60 -0
  395. package/tests/rank_change_broadcast_port.mjs +151 -0
  396. package/tests/seed_tasks.mjs +176 -0
  397. package/tests/seed_verify_criteria.mjs +2 -6
  398. package/tests/site_classified_once.mjs +142 -0
  399. package/tests/speciality_db_shape.mjs +23 -0
  400. package/tests/speciality_session_skills.mjs +48 -2
  401. package/tests/strand_lease.mjs +249 -0
  402. package/tests/strand_watch.mjs +10 -0
  403. package/tests/task_criteria_routes.mjs +275 -0
  404. package/tests/task_criteria_unlink_db.mjs +123 -0
  405. package/tests/task_needs.mjs +233 -0
  406. package/tests/task_needs_db.mjs +110 -0
  407. package/tests/unit_runner_serial.mjs +266 -43
  408. package/tests/version_literals.mjs +5 -7
  409. package/tests/welcome_boot.mjs +16 -5
  410. package/tests/welcome_boot_browser.mjs +36 -4
  411. package/docs/recipes/bongos-cli-release.md +0 -66
  412. package/scripts/bongos/backfill-disciplines.js +0 -144
  413. package/scripts/bongos/backfill-session-earnings.js +0 -123
  414. package/scripts/bongos/backfill-session-types.js +0 -87
  415. package/scripts/bongos/build-bongos.js +0 -94
  416. package/scripts/bongos/cleanup-orphans.js +0 -74
  417. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  418. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  419. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  420. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  421. package/scripts/bongos/smoke-batch-release.sh +0 -316
  422. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  423. package/scripts/bongos/smoke-dependencies.sh +0 -143
  424. package/scripts/gds/backfill-disciplines.js +0 -7
  425. package/scripts/gds/backfill-session-earnings.js +0 -7
  426. package/scripts/gds/backfill-session-types.js +0 -7
  427. package/scripts/gds/cleanup-orphans.js +0 -7
  428. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  429. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  430. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  431. package/scripts/gds/smoke-batch-claim.sh +0 -8
  432. package/scripts/gds/smoke-batch-release.sh +0 -8
  433. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  434. package/scripts/gds/smoke-dependencies.sh +0 -8
  435. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -0,0 +1,350 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/research.js — research ONE tracked item with ONE Messages API call
4
+ // (task 1004937, ADR 0394 §1).
5
+ //
6
+ // THE CALL. Model claude-sonnet-5, Anthropic's server-side web tools (web_search_20260209
7
+ // capped at 3 uses, web_fetch_20260209 capped at 2), a bounded max_tokens, and a JSON
8
+ // schema for the reply. The watch rules and both rubrics go first, in the system prompt,
9
+ // under cache_control: they are the same for every item of a run, so items 2..n read them
10
+ // from the cache. The item itself, and today's date, come after the cached prefix.
11
+ //
12
+ // NOT THE GRADE PORT, deliberately (ADR 0394 "Rejected options"): that port spawns a CLI
13
+ // the server image does not have, and it replays cached answers, which for news means
14
+ // finding nothing. This is the server's second direct LLM caller after discord-support.
15
+ // The model id is a single constant here so the ModelProvider port (ADR 0089 §3) can
16
+ // take it over later.
17
+ //
18
+ // TRUST NOTHING THE MODEL SAYS ABOUT ITS SOURCES. The reply is parsed and validated in
19
+ // code, never trusted from the prompt:
20
+ // - an entry survives only if its source_url is a URL that actually appeared in a web
21
+ // search result or a fetched page in THIS response. An invented or remembered link
22
+ // is dropped, so no entry is ever written without a real source (the task's rule);
23
+ // - scores survive only on the item's own rubric dimensions (or 'overall'), 0..10;
24
+ // - a 'new candidate' is returned as a candidate, and the run turns it into a
25
+ // proposal. Nothing here can add a tracked item.
26
+ // A refusal, a max_tokens or pause_turn stop, a tool error, or a reply that is not the
27
+ // JSON asked for ends the item with a recorded error. All of these arrive with HTTP 200
28
+ // and do not raise, so they are checked here, before any content is read.
29
+ //
30
+ // THE COST of each call is priced here from the response's own usage: tokens through
31
+ // llm-pricing.priceUsage plus web searches through llm-pricing.priceWebSearches (the one
32
+ // place the search fee is spelled). An item that errored after the model ran still cost
33
+ // money, so its cost is returned with the error.
34
+ //
35
+ // Pure apart from the injected client: tests pass a fake `messages.create` and read the
36
+ // request it was handed. Nothing here touches the network on its own.
37
+
38
+ const api = require('../../src/module-api');
39
+
40
+ const MODEL = 'claude-sonnet-5';
41
+ const SEARCH_MAX_USES = 3;
42
+ const FETCH_MAX_USES = 2;
43
+ const FETCH_MAX_CONTENT_TOKENS = 10_000;
44
+ const MAX_TOKENS = 8_000;
45
+ const EFFORT = 'low';
46
+ const CALL_TIMEOUT_MS = 5 * 60_000;
47
+
48
+ // AN ITEM'S CEILING (ADR 0394 §3): the most one item may cost, so no item starts unless
49
+ // all of it fits under the cap and the cap is never overshot by more than the estimate's
50
+ // error. Built from the bounds above at Sonnet 5's list rates ($2 in / $10 out per MTok):
51
+ // searches: 3 × $0.01 = $0.03
52
+ // output: 8,000 max_tokens × $10/MTok (thinking incl.)= $0.08
53
+ // input: an 80,000-token budget × $2/MTok = $0.16
54
+ // The input line is an ASSUMED bound, not one the API enforces: fetches are hard-capped at
55
+ // 2 × 10,000 tokens, but search results and the server loop's re-reads are not. The two
56
+ // measured items in ADR 0394 read 23k and 35k tokens of search content, so 80k leaves
57
+ // room. ≈ $0.27 per item against an expected ≈ $0.09.
58
+ const ASSUMED_MAX_INPUT_TOKENS = 80_000;
59
+ function itemCeilingUsd() {
60
+ const p = api.llmPricing;
61
+ const rate = p.PRICES[p.familyFor(MODEL)];
62
+ return p.priceWebSearches(SEARCH_MAX_USES)
63
+ + (MAX_TOKENS * rate.output) / 1e6
64
+ + (ASSUMED_MAX_INPUT_TOKENS * rate.input) / 1e6;
65
+ }
66
+
67
+ const ENTRY_TYPES = new Set(['insight', 'news', 'datapoint']);
68
+ const ITEM_KINDS = new Set(['competitor', 'partner', 'topic']);
69
+ const CONFIDENCE = Object.freeze({ low: 1, medium: 2, high: 3 });
70
+
71
+ // The reply's shape. Bounds that JSON schema strict mode cannot express (lengths, 0..10)
72
+ // are enforced in parseReply below.
73
+ const REPLY_SCHEMA = Object.freeze({
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ required: ['entries', 'scores', 'candidates'],
77
+ properties: {
78
+ entries: {
79
+ type: 'array',
80
+ items: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ required: ['type', 'takeaway', 'source_url', 'source_label', 'confidence', 'dimension'],
84
+ properties: {
85
+ type: { type: 'string', enum: ['insight', 'news', 'datapoint'] },
86
+ takeaway: { type: 'string' },
87
+ source_url: { type: 'string' },
88
+ source_label: { type: 'string' },
89
+ confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
90
+ dimension: { type: ['string', 'null'] },
91
+ },
92
+ },
93
+ },
94
+ scores: {
95
+ type: 'array',
96
+ items: {
97
+ type: 'object',
98
+ additionalProperties: false,
99
+ required: ['dimension', 'value', 'reason'],
100
+ properties: {
101
+ dimension: { type: 'string' },
102
+ value: { type: 'number' },
103
+ reason: { type: 'string' },
104
+ },
105
+ },
106
+ },
107
+ candidates: {
108
+ type: 'array',
109
+ items: {
110
+ type: 'object',
111
+ additionalProperties: false,
112
+ required: ['name', 'url', 'kind', 'why'],
113
+ properties: {
114
+ name: { type: 'string' },
115
+ url: { type: 'string' },
116
+ kind: { type: 'string', enum: ['competitor', 'partner', 'topic'] },
117
+ why: { type: 'string' },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ });
123
+
124
+ const oneLine = (s, max) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, max);
125
+ const isHttp = (u) => typeof u === 'string' && /^https?:\/\/[^\s]+$/.test(u);
126
+ const normUrl = (u) => String(u || '').trim().replace(/#.*$/, '').replace(/\/+$/, '');
127
+
128
+ // The system prompt: the rules and the rubrics. Stable for a whole run (the cache prefix),
129
+ // so it must not carry the date, the item, or anything else that varies per call.
130
+ function systemPrompt(rules) {
131
+ const r = rules || {};
132
+ const rubric = r.rubric || {};
133
+ const lines = [
134
+ 'You are the weekly market watch for a software project. You research ONE tracked item per request and answer only with the JSON the response format asks for.',
135
+ '',
136
+ '## The watch rules (approved by the project)',
137
+ oneLine(r.instructions, 4000) || 'Find what changed this week for the item.',
138
+ ];
139
+ if (Array.isArray(r.search_queries) && r.search_queries.length) {
140
+ lines.push('', 'Search themes:', ...r.search_queries.map((q) => `- ${oneLine(q, 300)}`));
141
+ }
142
+ if (Array.isArray(r.sources) && r.sources.length) {
143
+ lines.push('', 'Preferred sources:', ...r.sources.map((s) => `- ${oneLine(s, 300)}`));
144
+ }
145
+ for (const kind of Object.keys(rubric).sort()) {
146
+ const dims = Array.isArray(rubric[kind]) ? rubric[kind] : [];
147
+ if (!dims.length) continue;
148
+ lines.push('', `## How a ${kind} is scored (0 to 10 per dimension, plus "overall")`,
149
+ ...dims.map((d) => `- ${d.key}: ${oneLine(d.definition || d.label, 300)}`));
150
+ }
151
+ lines.push('',
152
+ '## How to answer',
153
+ `- Use at most ${SEARCH_MAX_USES} web searches and ${FETCH_MAX_USES} page fetches.`,
154
+ '- entries: only what is new or changed. Every entry needs source_url copied exactly from a search result or fetched page you saw in this request; an entry with any other link is thrown away. confidence: high only if two sources agree, low if one source and unconfirmed.',
155
+ '- scores: re-score each of the item\'s rubric dimensions and "overall", each with a one-line reason. Leave a dimension out if you found nothing that bears on it.',
156
+ '- candidates: companies, partners or topics this item\'s news points to that are not the item itself and look worth tracking. They are proposed to the team, never added by you.',
157
+ '- If nothing changed, return empty entries.');
158
+ return lines.join('\n');
159
+ }
160
+
161
+ function userPrompt(item, { today, since } = {}) {
162
+ const it = item || {};
163
+ return [
164
+ `Today is ${today}. Research what changed${since ? ` since ${since}` : ' in the last week'}.`,
165
+ '',
166
+ `Item: ${oneLine(it.name, 200)}`,
167
+ `Kind: ${it.kind}`,
168
+ it.url ? `Website: ${it.url}` : null,
169
+ it.description ? `What it is: ${oneLine(it.description, 500)}` : null,
170
+ it.why_tracked ? `Why we track it: ${oneLine(it.why_tracked, 500)}` : null,
171
+ ].filter((l) => l !== null).join('\n');
172
+ }
173
+
174
+ // buildRequest — the exact Messages API body for one item. Exported so the test asserts
175
+ // the model, the tool caps and the cache placement without a network.
176
+ function buildRequest({ item, rules, today, since }) {
177
+ return {
178
+ model: MODEL,
179
+ max_tokens: MAX_TOKENS,
180
+ system: [{ type: 'text', text: systemPrompt(rules), cache_control: { type: 'ephemeral' } }],
181
+ tools: [
182
+ { type: 'web_search_20260209', name: 'web_search', max_uses: SEARCH_MAX_USES },
183
+ { type: 'web_fetch_20260209', name: 'web_fetch', max_uses: FETCH_MAX_USES, max_content_tokens: FETCH_MAX_CONTENT_TOKENS },
184
+ ],
185
+ output_config: { effort: EFFORT, format: { type: 'json_schema', schema: REPLY_SCHEMA } },
186
+ messages: [{ role: 'user', content: userPrompt(item, { today, since }) }],
187
+ };
188
+ }
189
+
190
+ // The URLs this response actually saw: every web_search result and every fetched page.
191
+ function seenUrls(content) {
192
+ const seen = new Set();
193
+ for (const b of content || []) {
194
+ if (!b) continue;
195
+ if (b.type === 'web_search_tool_result' && Array.isArray(b.content)) {
196
+ for (const r of b.content) if (r && isHttp(r.url)) seen.add(normUrl(r.url));
197
+ } else if (b.type === 'web_fetch_tool_result' && b.content && !Array.isArray(b.content)) {
198
+ if (isHttp(b.content.url)) seen.add(normUrl(b.content.url));
199
+ }
200
+ }
201
+ return seen;
202
+ }
203
+
204
+ // A tool error block (HTTP 200, no exception). max_uses_exceeded is the cap doing its job
205
+ // (the model asked for one search too many and was refused), so it does not end the item.
206
+ function toolError(content) {
207
+ for (const b of content || []) {
208
+ if (!b) continue;
209
+ if (b.type === 'web_search_tool_result' || b.type === 'web_fetch_tool_result') {
210
+ const c = b.content;
211
+ const code = c && !Array.isArray(c) ? c.error_code : null;
212
+ if (code && code !== 'max_uses_exceeded') return String(code);
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // The reply text: the text blocks after the last server tool block (the final answer),
219
+ // or every text block if there were no tools.
220
+ function replyText(content) {
221
+ const blocks = content || [];
222
+ let start = 0;
223
+ blocks.forEach((b, i) => {
224
+ if (b && /tool_(use|result)$/.test(String(b.type))) start = i + 1;
225
+ });
226
+ const after = blocks.slice(start).filter((b) => b && b.type === 'text').map((b) => b.text);
227
+ const all = blocks.filter((b) => b && b.type === 'text').map((b) => b.text);
228
+ return (after.length ? after : all).join('');
229
+ }
230
+
231
+ // parseReply(text, { seen, dimensions }) → { ok: true, entries, scores, candidates, dropped }
232
+ // | { ok: false, error: 'malformed_reply' }. Pure.
233
+ function parseReply(text, { seen = new Set(), dimensions = [] } = {}) {
234
+ let obj = null;
235
+ const s = String(text || '').trim();
236
+ try { obj = JSON.parse(s); } catch (_) {
237
+ // A reply wrapped in a code fence or a sentence: take the outermost object, once.
238
+ const a = s.indexOf('{');
239
+ const z = s.lastIndexOf('}');
240
+ if (a !== -1 && z > a) { try { obj = JSON.parse(s.slice(a, z + 1)); } catch (_) { obj = null; } }
241
+ }
242
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)
243
+ || !Array.isArray(obj.entries) || !Array.isArray(obj.scores) || !Array.isArray(obj.candidates)) {
244
+ return { ok: false, error: 'malformed_reply' };
245
+ }
246
+ const dims = new Set([...dimensions, 'overall']);
247
+ const dropped = { entries: 0, scores: 0, candidates: 0 };
248
+
249
+ const entries = [];
250
+ for (const e of obj.entries) {
251
+ const url = e && typeof e.source_url === 'string' ? e.source_url.trim() : '';
252
+ const takeaway = oneLine(e && e.takeaway, 1000);
253
+ const conf = e && CONFIDENCE[String(e.confidence).toLowerCase()];
254
+ if (!isHttp(url) || !seen.has(normUrl(url)) || !takeaway || !conf || !ENTRY_TYPES.has(e.type)) {
255
+ dropped.entries += 1;
256
+ continue;
257
+ }
258
+ let host = '';
259
+ try { host = new URL(url).hostname.replace(/^www\./, ''); } catch (_) { /* isHttp passed */ }
260
+ entries.push({
261
+ type: e.type,
262
+ takeaway,
263
+ sourceUrl: url,
264
+ sourceLabel: oneLine(e.source_label, 200) || host,
265
+ confidence: conf,
266
+ dimension: typeof e.dimension === 'string' && dims.has(e.dimension) && e.dimension !== 'overall' ? e.dimension : null,
267
+ });
268
+ }
269
+
270
+ const scores = [];
271
+ const scored = new Set();
272
+ for (const sc of obj.scores) {
273
+ const v = sc && typeof sc.value === 'number' ? sc.value : NaN;
274
+ const reason = oneLine(sc && sc.reason, 300);
275
+ if (!sc || !dims.has(sc.dimension) || scored.has(sc.dimension) || !Number.isFinite(v) || v < 0 || v > 10 || !reason) {
276
+ dropped.scores += 1;
277
+ continue;
278
+ }
279
+ scored.add(sc.dimension);
280
+ scores.push({ dimension: sc.dimension, value: Math.round(v * 10) / 10, reason });
281
+ }
282
+
283
+ const candidates = [];
284
+ const named = new Set();
285
+ for (const c of obj.candidates) {
286
+ const name = oneLine(c && c.name, 200);
287
+ const key = name.toLowerCase();
288
+ if (!name || named.has(key) || !c || !ITEM_KINDS.has(c.kind) || !(c.url === '' || isHttp(c.url))) {
289
+ dropped.candidates += 1;
290
+ continue;
291
+ }
292
+ named.add(key);
293
+ candidates.push({ name, url: c.url, kind: c.kind, why: oneLine(c.why, 500) });
294
+ }
295
+ return { ok: true, entries, scores, candidates, dropped };
296
+ }
297
+
298
+ // priceResponse(usage) → USD for one response, tokens + searches, 4 decimals (the
299
+ // numeric(10,4) the run_items row stores).
300
+ function priceResponse(usage) {
301
+ const p = api.llmPricing;
302
+ const u = usage || {};
303
+ const tokens = p.priceUsage(MODEL, u).usd;
304
+ const searches = p.priceWebSearches(u.server_tool_use && u.server_tool_use.web_search_requests);
305
+ return Math.round((tokens + searches) * 10000) / 10000;
306
+ }
307
+
308
+ // createClient(apiKey) — the live SDK client on the PROJECT's key. Required lazily, so an
309
+ // instance with Market Watch off never loads the SDK.
310
+ function createClient(apiKey, { sdk = null } = {}) {
311
+ const mod = sdk || require('@anthropic-ai/sdk');
312
+ const Anthropic = mod.Anthropic || mod.default || mod;
313
+ return new Anthropic({ apiKey, timeout: CALL_TIMEOUT_MS, maxRetries: 1 });
314
+ }
315
+
316
+ // researchItem({ client, item, rules, today, since }) →
317
+ // { ok: true, entries, scores, candidates, dropped, costUsd, usage }
318
+ // { ok: false, error, costUsd, usage }
319
+ // Never throws: a thrown SDK error (network, 4xx, 5xx) is an item error that cost nothing.
320
+ async function researchItem({ client, item, rules, today, since }) {
321
+ const req = buildRequest({ item, rules, today, since });
322
+ let res;
323
+ try {
324
+ res = await client.messages.create(req);
325
+ } catch (e) {
326
+ const status = e && e.status ? ` ${e.status}` : '';
327
+ return { ok: false, error: `api_error${status}`, costUsd: 0, usage: null };
328
+ }
329
+ const usage = (res && res.usage) || {};
330
+ const costUsd = priceResponse(usage);
331
+ const stop = res && res.stop_reason;
332
+ if (stop === 'refusal' || stop === 'max_tokens' || stop === 'pause_turn') {
333
+ return { ok: false, error: stop, costUsd, usage };
334
+ }
335
+ const content = (res && res.content) || [];
336
+ const tErr = toolError(content);
337
+ if (tErr) return { ok: false, error: `tool_error:${tErr}`, costUsd, usage };
338
+ const rubric = (rules && rules.rubric && rules.rubric[item.kind]) || [];
339
+ const parsed = parseReply(replyText(content), {
340
+ seen: seenUrls(content),
341
+ dimensions: rubric.map((d) => d.key),
342
+ });
343
+ if (!parsed.ok) return { ok: false, error: parsed.error, costUsd, usage };
344
+ return { ...parsed, costUsd, usage };
345
+ }
346
+
347
+ module.exports = {
348
+ researchItem, buildRequest, parseReply, seenUrls, priceResponse, createClient, itemCeilingUsd,
349
+ MODEL, REPLY_SCHEMA,
350
+ };
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/run.js — the weekly Market Watch run (task 1004937, ADR 0394).
4
+ //
5
+ // One call, runDue(), decides and does everything a tick needs for ONE project:
6
+ //
7
+ // 1. An open run whose lease lapsed (a deploy cut it off) is RESUMED: it is claimed
8
+ // again and does only the items with no finished row. An item already done or
9
+ // errored is never researched, or charged, twice.
10
+ // 2. Otherwise, if the newest run is less than a cadence old (minus a 30-minute slack),
11
+ // nothing happens: due by the ledger, never by the boot.
12
+ // 3. Otherwise the pre-checks run, in this order, and the first that fails records a
13
+ // SKIPPED run with its reason — a row on the settings strip, never only a log line:
14
+ // off the owner's switch is off
15
+ // no_key no project key, or one the master key cannot open
16
+ // spend_unreadable this month's spend could not be read (FAIL CLOSED: an unknown
17
+ // spend is never treated as zero; agents/lib/spend-ceiling.js)
18
+ // cap_reached the cap is not a positive number, or this month's spend plus
19
+ // one item's whole ceiling would pass it
20
+ // error no approved (active) watch rules to run
21
+ // 4. A run row is created already leased (one open run per project, by index), and each
22
+ // item is researched in turn. BEFORE EACH ITEM the spend is read again and the cap
23
+ // checked again, so a run stops partway (status 'partial', reason cap_reached or
24
+ // spend_unreadable) rather than run past the cap.
25
+ // 5. Each item's result is written in one transaction, and its cost is mirrored into
26
+ // cost_log through the economy port: source `market-watch:<run id>`, source_ref
27
+ // `item-<item id>`, category 'api'. logCost is insert-once on (source, source_ref)
28
+ // and the mirror only runs when this process is the one that recorded the item, so
29
+ // a retry can never post it twice. Never via HTTP POST /cost.
30
+ //
31
+ // THE ACCOUNTING is market_watch_run_items.cost_usd: it is what the cap sums (db.monthSpend)
32
+ // and it is written in the same transaction as the item's findings. cost_log is the
33
+ // project-wide roll-up the cost dashboard reads, a projection like agents/spawn.js
34
+ // mirrorCostToLedger: best-effort, logged loudly when it fails, because the spend has
35
+ // already happened and the cap does not depend on it.
36
+ //
37
+ // ONE PROJECT. Every project is its own instance and database (ADR 0394 Context); the
38
+ // project key is the instance's own public origin. Every read and write is scoped to it
39
+ // (store.js puts the guard in the SQL), and an item that comes back from a read with any
40
+ // other project is refused here before a cent is spent on it.
41
+ //
42
+ // Everything is injected (store, clock, key reader, client factory, economy), so the
43
+ // whole run is tested with no database and no network.
44
+
45
+ const api = require('../../src/module-api');
46
+ const research = require('./research');
47
+ const { CADENCE_DAYS } = require('./db');
48
+
49
+ const log = api.logger('market-watch');
50
+
51
+ const DAY_MS = 24 * 60 * 60 * 1000;
52
+ const DUE_SLACK_MS = 30 * 60_000;
53
+ // Long enough for one item's call (5 min timeout) many times over; renewed before each
54
+ // item, so only a run whose process died lets it lapse.
55
+ const LEASE_MS = 30 * 60_000;
56
+
57
+ function costSourceTag(runId) {
58
+ return `market-watch:${runId}`;
59
+ }
60
+
61
+ // isDue(lastRun, cadence, nowMs) — the weekly clock, by the ledger.
62
+ function isDue(last, cadence, nowMs) {
63
+ if (!last) return true;
64
+ const at = new Date(last.created_at).getTime();
65
+ if (!Number.isFinite(at)) return true;
66
+ const days = CADENCE_DAYS[cadence] || CADENCE_DAYS.weekly;
67
+ return nowMs - at >= days * DAY_MS - DUE_SLACK_MS;
68
+ }
69
+
70
+ // capAllows(capUsd, spentUsd, ceilingUsd) — true only when one more item, at its whole
71
+ // ceiling, still fits. A cap that is not a positive finite number allows nothing.
72
+ function capAllows(capUsd, spentUsd, ceilingUsd) {
73
+ const cap = Number(capUsd);
74
+ if (!Number.isFinite(cap) || cap <= 0) return false;
75
+ if (!Number.isFinite(spentUsd) || spentUsd < 0) return false;
76
+ return spentUsd + ceilingUsd <= cap;
77
+ }
78
+
79
+ // readSpend — this month's spend, or null for "I don't know" (which always refuses).
80
+ async function readSpend(store, project, now) {
81
+ try {
82
+ const n = Number(await store.monthSpend(project, now));
83
+ return Number.isFinite(n) && n >= 0 ? n : null;
84
+ } catch (_) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // The pre-checks of step 3. Returns { reason } or { key, cap, spent, rules }.
90
+ async function precheck(deps, project, settings, now) {
91
+ if (!settings || settings.enabled !== true) return { reason: 'off' };
92
+ let key = null;
93
+ try { key = await deps.readKey(project); } catch (_) { key = null; }
94
+ if (!key) return { reason: 'no_key' };
95
+ const cap = Number(settings.monthly_cap_usd);
96
+ const spent = await readSpend(deps.store, project, now);
97
+ if (spent === null) return { reason: 'spend_unreadable', cap };
98
+ if (!capAllows(cap, spent, deps.ceilingUsd)) return { reason: 'cap_reached', cap, spent };
99
+ const rules = await deps.store.activeRules(project);
100
+ if (!rules) return { reason: 'error', cap, spent, note: 'no active watch rules' };
101
+ return { key, cap, spent, rules };
102
+ }
103
+
104
+ async function mirrorCost(deps, project, runId, item, costUsd) {
105
+ if (!(costUsd > 0)) return false;
106
+ const economy = deps.economy
107
+ || (typeof api.resolveOptional === 'function' ? api.resolveOptional('reward', null) : null);
108
+ if (!economy || typeof economy.logCost !== 'function') {
109
+ deps.warn(`[weekly-run] run ${runId} item ${item.id}: no economy port, cost_log is missing $${costUsd}`);
110
+ return false;
111
+ }
112
+ try {
113
+ await economy.logCost({
114
+ amountUsd: costUsd,
115
+ category: 'api',
116
+ source: costSourceTag(runId),
117
+ sourceRef: `item-${item.id}`,
118
+ // The project pays (its own key); no builder asked for a weekly run.
119
+ builderId: null,
120
+ description: `Market Watch run ${runId}: ${item.name} (${project})`.slice(0, 1000),
121
+ });
122
+ return true;
123
+ } catch (e) {
124
+ deps.warn(`[weekly-run] run ${runId} item ${item.id}: cost mirror failed (${e && e.message}); run_items still has the cost`);
125
+ return false;
126
+ }
127
+ }
128
+
129
+ // Research every remaining item of one claimed run, then finish it.
130
+ async function execute(deps, project, runId, ctx) {
131
+ const { store } = deps;
132
+ const client = deps.createClient(ctx.key);
133
+ const items = await store.remainingItems(project, runId);
134
+ const nowDate = new Date(deps.now());
135
+ const today = nowDate.toISOString().slice(0, 10);
136
+ const out = { runId, done: 0, errors: 0, refused: 0, entries: 0, scores: 0, proposals: 0, costUsd: 0 };
137
+ let stop = null;
138
+
139
+ for (const item of items) {
140
+ // The cross-project guard, before any spend: a row from another project is refused.
141
+ if (!item || item.project !== project) {
142
+ out.refused += 1;
143
+ deps.warn(`[weekly-run] run ${runId}: refused item ${item && item.id} from another project`);
144
+ continue;
145
+ }
146
+ const spent = await readSpend(store, project, new Date(deps.now()));
147
+ if (spent === null) { stop = 'spend_unreadable'; break; }
148
+ if (!capAllows(ctx.cap, spent, deps.ceilingUsd)) { stop = 'cap_reached'; break; }
149
+ if (!(await store.renewLease(project, runId, LEASE_MS))) { stop = 'lease_lost'; break; }
150
+ if (!(await store.startItem(project, runId, item.id))) continue;
151
+
152
+ const res = await deps.researchItem({ client, item, rules: ctx.rules, today });
153
+ let rec;
154
+ try {
155
+ rec = await store.recordItem(project, runId, item.id, res);
156
+ } catch (e) {
157
+ if (e && e.code === 'cross_project') {
158
+ out.refused += 1;
159
+ deps.warn(`[weekly-run] run ${runId}: item ${item.id} is not this project's; nothing written`);
160
+ continue;
161
+ }
162
+ throw e;
163
+ }
164
+ if (!rec.recorded) continue;
165
+ out.costUsd += res.costUsd || 0;
166
+ await mirrorCost(deps, project, runId, item, res.costUsd);
167
+ if (res.ok) {
168
+ out.done += 1;
169
+ out.entries += rec.counts.entries;
170
+ out.scores += rec.counts.scores;
171
+ out.proposals += rec.counts.proposals;
172
+ } else {
173
+ out.errors += 1;
174
+ deps.warn(`[weekly-run] run ${runId}: ${item.name} ended with ${res.error}`);
175
+ }
176
+ }
177
+
178
+ if (stop === 'lease_lost') {
179
+ // Another process holds the run now; leave it to them.
180
+ return { outcome: 'lease_lost', ...out };
181
+ }
182
+ let status = 'done';
183
+ let reason = null;
184
+ if (stop) { status = 'partial'; reason = stop; } else if (out.errors > 0 && out.done === 0) { status = 'failed'; reason = 'error'; } else if (out.errors > 0) { status = 'partial'; reason = 'error'; }
185
+ await store.finishRun(project, runId, { status, reason });
186
+ return { outcome: status, reason, ...out };
187
+ }
188
+
189
+ function resolveDeps(deps) {
190
+ return {
191
+ store: deps.store,
192
+ now: deps.now || Date.now,
193
+ readKey: deps.readKey,
194
+ createClient: deps.createClient || ((key) => research.createClient(key)),
195
+ researchItem: deps.researchItem || research.researchItem,
196
+ ceilingUsd: deps.ceilingUsd != null ? deps.ceilingUsd : research.itemCeilingUsd(),
197
+ economy: deps.economy || null,
198
+ warn: deps.warn || ((m) => log.warn(m)),
199
+ };
200
+ }
201
+
202
+ // runDue({ store, project, readKey, ... }) — one tick's worth of work for one project.
203
+ async function runDue(rawDeps = {}) {
204
+ const deps = resolveDeps(rawDeps);
205
+ const { store } = deps;
206
+ const project = rawDeps.project;
207
+ if (typeof project !== 'string' || !project) return { skipped: 'no_project' };
208
+ const nowMs = deps.now();
209
+ const now = new Date(nowMs);
210
+
211
+ const open = await store.openRun(project);
212
+ if (open) {
213
+ if (open.leased) return { skipped: 'in_flight', runId: open.id };
214
+ if (!(await store.claimRun(project, open.id, LEASE_MS))) return { skipped: 'in_flight', runId: open.id };
215
+ const settings = await store.readSettings(project);
216
+ const pre = await precheck(deps, project, settings, now);
217
+ if (pre.reason) {
218
+ await store.finishRun(project, open.id, { status: 'partial', reason: pre.reason });
219
+ return { outcome: 'partial', reason: pre.reason, runId: open.id, resumed: true };
220
+ }
221
+ return { resumed: true, ...(await execute(deps, project, open.id, pre)) };
222
+ }
223
+
224
+ const settings = await store.readSettings(project);
225
+ const last = await store.lastRun(project);
226
+ if (!isDue(last, settings.cadence, nowMs)) return { skipped: 'not_due' };
227
+
228
+ const pre = await precheck(deps, project, settings, now);
229
+ if (pre.reason) {
230
+ const runId = await store.insertSkipped(project, {
231
+ reason: pre.reason,
232
+ capUsd: Number.isFinite(pre.cap) ? pre.cap : null,
233
+ spentBeforeUsd: Number.isFinite(pre.spent) ? pre.spent : null,
234
+ });
235
+ if (pre.note) deps.warn(`[weekly-run] run ${runId} skipped: ${pre.note}`);
236
+ return { outcome: 'skipped', reason: pre.reason, runId };
237
+ }
238
+ const runId = await store.createRun(project, {
239
+ ruleVersionId: pre.rules.id, capUsd: pre.cap, spentBeforeUsd: pre.spent, leaseMs: LEASE_MS,
240
+ });
241
+ if (runId === null) return { skipped: 'in_flight' };
242
+ return execute(deps, project, runId, pre);
243
+ }
244
+
245
+ module.exports = { runDue, isDue, costSourceTag };