@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
@@ -118,6 +118,12 @@ const DEFAULT_READ_LIMIT = 600;
118
118
  // address-rotating caller must not be able to grow the map without bound between sweeps
119
119
  // (same bound as modules/platform-identity/read-rate-limit.js).
120
120
  const DEFAULT_READ_MAX_TRACKED_IPS = 10000;
121
+ // Hard size cap on EVERY bucket map below (task 1004964). The sweep bounds them by
122
+ // time only, once per SWEEP_MS; a flood from many source addresses would otherwise
123
+ // grow a map for up to five minutes before it runs. At the cap the oldest-inserted
124
+ // key is evicted first (a Map iterates in insertion order), so the newest callers
125
+ // keep their buckets. A limiter may set a tighter cap (DEFAULT_READ_MAX_TRACKED_IPS).
126
+ const MAX_TRACKED_KEYS = 50000;
121
127
  const OPT_OUT_REASON_MIN_LENGTH = 20;
122
128
  const SWEEP_MS = 5 * 60 * 1000;
123
129
 
@@ -138,6 +144,15 @@ const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
138
144
  // limiter — the public router answers CORS preflights and returns 204 first.
139
145
  const READ_METHODS = new Set(['GET', 'HEAD']);
140
146
 
147
+ // newBucket(map, key, cap) — start an empty bucket for a key not yet tracked,
148
+ // evicting the oldest-inserted key first while the map is at its cap.
149
+ function newBucket(map, key, cap = MAX_TRACKED_KEYS) {
150
+ while (map.size >= cap) map.delete(map.keys().next().value);
151
+ const bucket = [];
152
+ map.set(key, bucket);
153
+ return bucket;
154
+ }
155
+
141
156
  function pruneAndCount(bucket, now, windowMs) {
142
157
  const cutoff = now - windowMs;
143
158
  // arr is the in-place timestamps array; trim in place.
@@ -219,7 +234,7 @@ function rateLimitMiddleware(req, res, next) {
219
234
  // --- per-IP ---
220
235
  const ip = req.ip || req.socket?.remoteAddress || 'unknown';
221
236
  let ipBucket = ipBuckets.get(ip);
222
- if (!ipBucket) { ipBucket = []; ipBuckets.set(ip, ipBucket); }
237
+ if (!ipBucket) ipBucket = newBucket(ipBuckets, ip);
223
238
  const ipCount = pruneAndCount(ipBucket, now, PER_IP_WINDOW_MS);
224
239
  if (ipCount >= PER_IP_LIMIT) {
225
240
  const oldest = ipBucket[0];
@@ -235,7 +250,7 @@ function rateLimitMiddleware(req, res, next) {
235
250
  const sessionKey = extractSessionKey(req);
236
251
  if (sessionKey) {
237
252
  let sBucket = sessionBuckets.get(sessionKey);
238
- if (!sBucket) { sBucket = []; sessionBuckets.set(sessionKey, sBucket); }
253
+ if (!sBucket) sBucket = newBucket(sessionBuckets, sessionKey);
239
254
  const sCount = pruneAndCount(sBucket, now, PER_SESSION_WINDOW_MS);
240
255
  if (sCount >= PER_SESSION_LIMIT) {
241
256
  const oldest = sBucket[0];
@@ -266,7 +281,7 @@ function publicReadRateLimitMiddleware(req, res, next) {
266
281
  const now = Date.now();
267
282
  const ip = req.ip || req.socket?.remoteAddress || 'unknown';
268
283
  let bucket = publicReadBuckets.get(ip);
269
- if (!bucket) { bucket = []; publicReadBuckets.set(ip, bucket); }
284
+ if (!bucket) bucket = newBucket(publicReadBuckets, ip);
270
285
  const count = pruneAndCount(bucket, now, PUBLIC_READ_WINDOW_MS);
271
286
  if (count >= PUBLIC_READ_LIMIT) {
272
287
  const oldest = bucket[0];
@@ -287,18 +302,14 @@ function publicReadRateLimitMiddleware(req, res, next) {
287
302
  // names that budget in the 429 body + headers so a caller can tell which ceiling
288
303
  // it hit. Keyed on req.ip — the same real-client-IP resolution the write limiter
289
304
  // uses (Caddy forwards the client IP, server sets 'trust proxy').
290
- function perIpSlidingWindow(buckets, { limit, windowMs, scope, maxTracked = Infinity }) {
305
+ function perIpSlidingWindow(buckets, { limit, windowMs, scope, maxTracked = MAX_TRACKED_KEYS }) {
291
306
  return markMetered(function slidingWindowRateLimit(req, res, next) {
292
307
  const now = Date.now();
293
308
  const ip = req.ip || req.socket?.remoteAddress || 'unknown';
294
309
  let bucket = buckets.get(ip);
295
- if (!bucket) {
296
- // Bound the map: a caller rotating source addresses (an IPv6 /64) mints a fresh
297
- // bucket per request. Evict the longest-tracked entry (Map iterates oldest first).
298
- if (buckets.size >= maxTracked) buckets.delete(buckets.keys().next().value);
299
- bucket = [];
300
- buckets.set(ip, bucket);
301
- }
310
+ // Bound the map: a caller rotating source addresses (an IPv6 /64) mints a fresh
311
+ // bucket per request. Evict the longest-tracked entry (Map iterates oldest first).
312
+ if (!bucket) bucket = newBucket(buckets, ip, maxTracked);
302
313
  if (pruneAndCount(bucket, now, windowMs) >= limit) {
303
314
  const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
304
315
  res.set('Retry-After', String(retryAfter));
@@ -451,6 +462,7 @@ module.exports = {
451
462
  selfMeteredRoutes,
452
463
  DEFAULT_READ_LIMIT,
453
464
  DEFAULT_READ_MAX_TRACKED_IPS,
465
+ MAX_TRACKED_KEYS,
454
466
  // exported for tests
455
467
  PER_IP_WINDOW_MS,
456
468
  PER_IP_LIMIT,
@@ -463,3 +475,10 @@ module.exports = {
463
475
  PROJECT_FEED_WINDOW_MS,
464
476
  PROJECT_FEED_LIMIT,
465
477
  };
478
+
479
+ // Test seam (task 1004964): the bucket maps, so a test can show each stays capped.
480
+ // Assigned after the export list, not inside it: an object literal there stops
481
+ // cjs-module-lexer, and ESM tests lose every named export after it.
482
+ module.exports._bucketMaps = {
483
+ ipBuckets, sessionBuckets, publicReadBuckets, accountExistenceBuckets, projectFeedBuckets, defaultReadBuckets,
484
+ };
@@ -50,7 +50,7 @@ const MODULE_GLOBS = {
50
50
  'src/bongos/permission-path-check.js', 'src/bongos/sensitive-surfaces.js',
51
51
  // task 1004024: the auth.js carve (db-kernel.js, above, is db.js's), now protected.
52
52
  'src/bongos/auth-admission.js', 'src/bongos/auth-config.js', 'src/bongos/auth-discord.js',
53
- 'src/bongos/auth-github.js', 'src/bongos/auth-idp.js',
53
+ 'src/bongos/auth-github.js', 'src/bongos/auth-google.js', 'src/bongos/auth-idp.js',
54
54
  'src/bongos/middleware/', 'src/bongos/live-channel.js', 'src/bongos/secret-box.js',
55
55
  'src/bongos/repo-info.js', 'src/bongos/api-path-404.js', 'src/bongos/serve-internal.js',
56
56
  'src/bongos/module-scope-map.js', 'src/bongos/hierarchy-config.js',
@@ -190,6 +190,15 @@ const MODULE_GLOBS = {
190
190
  raise: [
191
191
  'modules/raise/',
192
192
  ],
193
+ // Market Watch (task 1004935, goal 1000137): a weekly AI watch on a project's
194
+ // market. default:false; its data layer landed in task 1004936 (no routes yet). Its tests
195
+ // are listed by EXACT path: this file's format has no wildcards (header above), so a
196
+ // 'tests/market_watch_*' entry would match nothing. Add each new market_watch test here by name.
197
+ 'market-watch': [
198
+ 'modules/market-watch/', 'tests/market_watch_module.mjs',
199
+ 'tests/market_watch_data.mjs', 'tests/market_watch_data_db.mjs',
200
+ 'tests/market_watch_run.mjs',
201
+ ],
193
202
  // --- builder prefs (wandering/skill/sound/render) + builder-needs ---
194
203
  // Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
195
204
  // libs + builder-needs + their db slice + the `/me/*-prefs` routes + the
@@ -529,6 +538,8 @@ const MODULE_GLOBS = {
529
538
  // enforcement, not methodology prose. ---
530
539
  methodology: [
531
540
  'CLAUDE.md',
541
+ // the same instruction core for every other AI model (task 1004922)
542
+ 'AGENTS.md', 'GEMINI.md',
532
543
  'docs/',
533
544
  '.claude/skills/', '.claude/rules/',
534
545
  'scripts/bongos/start.js', 'scripts/bongos/discipline-modes.json',
@@ -29,6 +29,7 @@
29
29
  // per-task est_minutes <= timeBudgetMin guard already enforces that.
30
30
 
31
31
  const { pool } = require('./pool');
32
+ const { resolveCapabilities, feedNeedsClause } = require('./task-needs');
32
33
  // touches[] cleanup [5/8] (task 880): the same prefix-aware overlap matcher the
33
34
  // claim guard uses, so the planner's "parallel-safe set" agrees with what
34
35
  // claimTask / claimTasksBatch will actually allow.
@@ -77,6 +78,12 @@ const UNSHIPPED_DEPS_GUARD = `
77
78
  OR (d.to_kind = 'goal' AND EXISTS (SELECT 1 FROM goals x WHERE x.id = d.to_id AND x.status <> 'achieved')))
78
79
  )`;
79
80
 
81
+ // An artist's page claim is a MARKER, never a file-overlap block (task 1004947).
82
+ // Hand copy of notPageMarkerSql('c', 't2') in modules/lifecycle/page-tweak-artist.js
83
+ // (core cannot require a module, ADR 0083); tests/page_tweak_marker_sql.mjs pins
84
+ // the two together, as claimable_blocker_status.mjs does for the guards above.
85
+ const PAGE_MARKER_GUARD = `NOT (COALESCE(t2.source, '') = 'page-tweak' AND c.worktree_name IS NULL AND c.creator_session_id IS NULL)`;
86
+
80
87
  const VALID_MODES = ['active', 'check-in', 'autonomous'];
81
88
  const DEFAULT_MAX_PARALLEL = 3;
82
89
 
@@ -108,6 +115,11 @@ async function optimizeSession({ builderId, mode, timeBudgetMin, maxParallel })
108
115
  // whose blocker had been resolved stayed invisible here. A comment asking the
109
116
  // next editor to remember is not enforcement: tests/claimable_blocker_status.mjs
110
117
  // now asserts this file, db-tasks.js and the migration carry the same predicate.
118
+ // task 1004950 / ADR 0395: recommend only what this builder can do — the same
119
+ // rule as listClaimableTasks. An autonomous plan runs unattended, so it never
120
+ // holds `owner` (an agent cannot make the owner's decision).
121
+ const caps = await resolveCapabilities(builderId);
122
+ const feedCaps = mode === 'autonomous' ? caps.filter((c) => c !== 'owner') : caps;
111
123
  const { rows } = await pool.query(
112
124
  `SELECT ct.*, sf.fits_session_modes, sf.credits_per_minute
113
125
  FROM (
@@ -120,6 +132,7 @@ ${OPEN_BLOCKER_GUARD}${UNSHIPPED_DEPS_GUARD}
120
132
  SELECT 1 FROM claims c
121
133
  JOIN tasks t2 ON t2.id = c.task_id
122
134
  WHERE c.released_at IS NULL
135
+ AND ${PAGE_MARKER_GUARD}
123
136
  AND t2.touches && t.touches
124
137
  )
125
138
  ) ct
@@ -142,6 +155,7 @@ ${OPEN_BLOCKER_GUARD}${UNSHIPPED_DEPS_GUARD}
142
155
  FROM tasks t
143
156
  ) sf ON sf.id = ct.id
144
157
  WHERE $1 = ANY(sf.fits_session_modes)
158
+ AND ${feedNeedsClause('ct', '$3')}
145
159
  AND NOT EXISTS (
146
160
  SELECT 1 FROM claims c
147
161
  WHERE c.task_id = ct.id
@@ -152,7 +166,7 @@ ${OPEN_BLOCKER_GUARD}${UNSHIPPED_DEPS_GUARD}
152
166
  ct.priority ASC NULLS LAST,
153
167
  COALESCE(ct.est_minutes_calibrated, ct.est_minutes) ASC NULLS LAST,
154
168
  ct.id ASC`,
155
- [mode, builderId]
169
+ [mode, builderId, feedCaps]
156
170
  );
157
171
 
158
172
  const chosen = [];
@@ -0,0 +1,342 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/payout-approvals.js — every payout needs a logged, personal approval
4
+ // (task 1004913, goal 1000092 N9; ADR 0385 decisions 4 and 8, ADR 0387 decision 3).
5
+ //
6
+ // WHY. The owner's legal framework (Art. I, cited by Article only) holds that a
7
+ // payout, an equity grant or a contract is never executed by the platform or an AI
8
+ // on its own: each needs a distinct step by the project's authorised person, and an
9
+ // append-only log of who approved what and when. If we cannot prove the person
10
+ // pressed the button, we were the actor. This file is that step and that log; the
11
+ // money-moving tasks (M04 pays a builder, N6 module sales, N7 cash-out) call
12
+ // checkRun / checkApproval before they instruct the processor, and never move a
13
+ // cent on anything else.
14
+ //
15
+ // THE SHAPE (migrations/core_283_payout_approvals.sql). One append-only table of
16
+ // events. A standing payout rule is its `rule_signed` row; it is narrowed or
17
+ // revoked by later rows, never edited. Runs under a rule are `run_noticed` (the
18
+ // one kind a runner may file: it authorises nothing) and become payable only after
19
+ // the rule's pause window, unless a person files `run_held`. One-off payouts,
20
+ // equity grants and contracts are one row and one signature each — there is no
21
+ // batch form, on purpose.
22
+ //
23
+ // WHO. The routes gate on the `payout.approve` permission (rank read fresh per
24
+ // request, ADR 0016) and then demand a PERSONAL session: the person's own web
25
+ // session, never a CLI token (that is what an AI session runs on), never a machine
26
+ // principal. The database repeats both rules (a CHECK on session_source and a
27
+ // trigger on builders.system_role), so a route that forgot cannot write a
28
+ // signature an AI made. An AI may never create or widen a rule: creating needs a
29
+ // personal session, and widening does not exist — a narrowing that would widen is
30
+ // refused here AND by the trigger, so the only way to a bigger rule is a person
31
+ // signing a new one.
32
+ //
33
+ // Nothing here moves money (ADR 0385 D1): this is the authority a processor
34
+ // transfer must cite. Pure validators first, then the db half (pool injectable).
35
+
36
+ const MAX_CENTS = 100_000_000_00; // $100M: a sanity ceiling, not a policy
37
+ const MAX_PAUSE_HOURS = 720;
38
+ const FORMULA_KINDS = Object.freeze(['task_pay_terms', 'fixed_per_task', 'credit_share']);
39
+ const SCHEDULES = Object.freeze(['on_ship', 'weekly', 'monthly']);
40
+ const CAP_KEYS = Object.freeze(['per_payout_cents', 'per_run_cents', 'per_month_cents']);
41
+ const SIGNED_KINDS = Object.freeze(['rule_signed', 'rule_narrowed', 'rule_revoked', 'run_held', 'payout_approved', 'grant_signed', 'contract_signed']);
42
+ const RUN_KEY_RE = /^[A-Za-z0-9._:-]{1,80}$/;
43
+ const CURRENCY_RE = /^[A-Z]{3}$/;
44
+ const NOTE_MAX = 500;
45
+
46
+ const isCents = (v) => Number.isSafeInteger(v) && v > 0 && v <= MAX_CENTS;
47
+ const isId = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0) || (typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v));
48
+
49
+ function cleanNote(raw) {
50
+ if (raw === undefined || raw === null || raw === '') return { value: null };
51
+ if (typeof raw !== 'string') return { error: 'note must be text' };
52
+ const v = raw.replace(/\s+/g, ' ').trim();
53
+ if (v.length > NOTE_MAX) return { error: `note must be ${NOTE_MAX} characters or fewer` };
54
+ return { value: v || null };
55
+ }
56
+
57
+ // A standing payout rule's terms, strictly: every field required that a run is
58
+ // checked against, nothing unknown accepted (a person signs exactly what is stored).
59
+ function validateRuleTerms(raw) {
60
+ const errors = [];
61
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { errors: ['terms must be an object'] };
62
+ const known = new Set(['caps', 'currency', 'formula', 'schedule', 'pause_hours']);
63
+ for (const k of Object.keys(raw)) if (!known.has(k)) errors.push(`unknown term: ${k}`);
64
+
65
+ const caps = raw.caps;
66
+ const outCaps = {};
67
+ if (!caps || typeof caps !== 'object' || Array.isArray(caps)) {
68
+ errors.push('caps must be an object');
69
+ } else {
70
+ for (const k of Object.keys(caps)) if (!CAP_KEYS.includes(k)) errors.push(`unknown cap: ${k}`);
71
+ for (const k of ['per_payout_cents', 'per_run_cents']) {
72
+ if (!isCents(caps[k])) errors.push(`caps.${k} must be a whole number of cents above zero`);
73
+ else outCaps[k] = caps[k];
74
+ }
75
+ if (caps.per_month_cents !== undefined && caps.per_month_cents !== null) {
76
+ if (!isCents(caps.per_month_cents)) errors.push('caps.per_month_cents must be a whole number of cents above zero, or left out');
77
+ else outCaps.per_month_cents = caps.per_month_cents;
78
+ }
79
+ if (outCaps.per_payout_cents && outCaps.per_run_cents && outCaps.per_payout_cents > outCaps.per_run_cents) {
80
+ errors.push('caps.per_payout_cents may not be above caps.per_run_cents');
81
+ }
82
+ if (outCaps.per_run_cents && outCaps.per_month_cents && outCaps.per_run_cents > outCaps.per_month_cents) {
83
+ errors.push('caps.per_run_cents may not be above caps.per_month_cents');
84
+ }
85
+ }
86
+
87
+ if (typeof raw.currency !== 'string' || !CURRENCY_RE.test(raw.currency)) errors.push('currency must be a three-letter code such as USD');
88
+
89
+ const f = raw.formula;
90
+ let formula = null;
91
+ if (!f || typeof f !== 'object' || Array.isArray(f) || !FORMULA_KINDS.includes(f.kind)) {
92
+ errors.push(`formula.kind must be one of ${FORMULA_KINDS.join(', ')}`);
93
+ } else if (f.kind === 'fixed_per_task') {
94
+ if (!isCents(f.amount_cents)) errors.push('formula.amount_cents must be a whole number of cents above zero');
95
+ else formula = { kind: f.kind, amount_cents: f.amount_cents };
96
+ for (const k of Object.keys(f)) if (k !== 'kind' && k !== 'amount_cents') errors.push(`unknown formula field: ${k}`);
97
+ } else if (f.kind === 'credit_share') {
98
+ // ADR 0387 D5: a pool run names the credit row kinds it counts.
99
+ const kinds = f.credit_row_kinds;
100
+ if (!Array.isArray(kinds) || kinds.length === 0 || kinds.some((k) => typeof k !== 'string' || !/^[a-z_.]{1,40}$/.test(k))) {
101
+ errors.push('formula.credit_row_kinds must name the credit row kinds the pool counts');
102
+ } else {
103
+ formula = { kind: f.kind, credit_row_kinds: [...new Set(kinds)].sort() };
104
+ }
105
+ for (const k of Object.keys(f)) if (k !== 'kind' && k !== 'credit_row_kinds') errors.push(`unknown formula field: ${k}`);
106
+ } else {
107
+ formula = { kind: f.kind };
108
+ for (const k of Object.keys(f)) if (k !== 'kind') errors.push(`unknown formula field: ${k}`);
109
+ }
110
+
111
+ if (!SCHEDULES.includes(raw.schedule)) errors.push(`schedule must be one of ${SCHEDULES.join(', ')}`);
112
+ if (!Number.isSafeInteger(raw.pause_hours) || raw.pause_hours < 1 || raw.pause_hours > MAX_PAUSE_HOURS) {
113
+ errors.push(`pause_hours must be a whole number from 1 to ${MAX_PAUSE_HOURS}`);
114
+ }
115
+
116
+ if (errors.length) return { errors };
117
+ return { terms: { caps: outCaps, currency: raw.currency, formula, schedule: raw.schedule, pause_hours: raw.pause_hours } };
118
+ }
119
+
120
+ // The narrowing rule, the same one the trigger enforces: caps only go down (and a
121
+ // set cap is never removed), the pause only gets longer, and the formula,
122
+ // schedule and currency do not move. Returns the reasons a change would widen.
123
+ function wideningReasons(current, next) {
124
+ const out = [];
125
+ if (JSON.stringify(current.formula) !== JSON.stringify(next.formula)) out.push('the formula would change');
126
+ if (current.schedule !== next.schedule) out.push('the schedule would change');
127
+ if (current.currency !== next.currency) out.push('the currency would change');
128
+ if (next.pause_hours < current.pause_hours) out.push('the pause window would get shorter');
129
+ for (const k of CAP_KEYS) {
130
+ const was = current.caps[k];
131
+ const now = next.caps[k];
132
+ if (was !== undefined && was !== null && (now === undefined || now === null)) out.push(`the ${k} cap would be removed`);
133
+ else if (was !== undefined && was !== null && now > was) out.push(`the ${k} cap would go up`);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // One personal act: the person's own web session, a human builder. Everything a
139
+ // route needs to refuse an AI, in one place (the DB repeats it).
140
+ function personalSession(req) {
141
+ const s = req && req.gdsSession;
142
+ const b = req && req.builder;
143
+ if (!b || !s) return { ok: false, reason: 'no_session' };
144
+ if (b.system_role) return { ok: false, reason: 'machine_principal' };
145
+ if (s.source !== 'web') return { ok: false, reason: 'not_a_web_session' };
146
+ if (s.session_id === undefined || s.session_id === null) return { ok: false, reason: 'no_session' };
147
+ return { ok: true, actor: { id: b.id, rank: b.rank || null, session_id: s.session_id, session_source: 'web' } };
148
+ }
149
+
150
+ // Fold a rule's events into its current state. events: its rows, oldest first,
151
+ // the signing row included.
152
+ function foldRule(events) {
153
+ const signed = events.find((e) => e.kind === 'rule_signed');
154
+ if (!signed) return null;
155
+ let terms = signed.terms;
156
+ let revoked = null;
157
+ for (const e of events) {
158
+ if (e.kind === 'rule_narrowed') terms = e.terms;
159
+ if (e.kind === 'rule_revoked') revoked = { by: e.actor_id, at: e.created_at };
160
+ }
161
+ return { id: signed.id, signed_by: signed.actor_id, signed_at: signed.created_at, terms, revoked, active: !revoked };
162
+ }
163
+
164
+ const MONTH = (d) => new Date(d).toISOString().slice(0, 7);
165
+
166
+ // May this run pay now? rule: foldRule's state; ruleEvents: the rule's rows;
167
+ // payouts: [{ recipient_id, amount_cents }]. Pure; checkRun reads the rows.
168
+ function judgeRun({ rule, ruleEvents, runKey, payouts, now = new Date() }) {
169
+ if (!rule) return { ok: false, reason: 'no_such_rule' };
170
+ if (!rule.active) return { ok: false, reason: 'rule_revoked' };
171
+ const notice = ruleEvents.find((e) => e.kind === 'run_noticed' && e.run_key === runKey);
172
+ if (!notice) return { ok: false, reason: 'run_not_noticed' };
173
+ if (ruleEvents.some((e) => e.kind === 'run_held' && e.run_key === runKey)) return { ok: false, reason: 'run_held' };
174
+ const opensAt = new Date(new Date(notice.created_at).getTime() + rule.terms.pause_hours * 3600_000);
175
+ if (now < opensAt) return { ok: false, reason: 'in_pause_window', opens_at: opensAt.toISOString() };
176
+ const noticed = notice.terms && Array.isArray(notice.terms.payouts) ? notice.terms.payouts : [];
177
+ const key = (list) => JSON.stringify(list.map((p) => [String(p.recipient_id), Number(p.amount_cents)]).sort());
178
+ if (!Array.isArray(payouts) || key(payouts) !== key(noticed)) return { ok: false, reason: 'run_differs_from_notice' };
179
+ const caps = rule.terms.caps;
180
+ if (noticed.some((p) => Number(p.amount_cents) > caps.per_payout_cents)) return { ok: false, reason: 'over_per_payout_cap' };
181
+ if (Number(notice.amount_cents) > caps.per_run_cents) return { ok: false, reason: 'over_per_run_cap' };
182
+ if (caps.per_month_cents) {
183
+ const month = MONTH(notice.created_at);
184
+ const held = new Set(ruleEvents.filter((e) => e.kind === 'run_held').map((e) => e.run_key));
185
+ const total = ruleEvents
186
+ .filter((e) => e.kind === 'run_noticed' && !held.has(e.run_key) && MONTH(e.created_at) === month && e.id <= notice.id)
187
+ .reduce((s, e) => s + Number(e.amount_cents), 0);
188
+ if (total > caps.per_month_cents) return { ok: false, reason: 'over_per_month_cap' };
189
+ }
190
+ return { ok: true, rule_id: rule.id, notice_id: notice.id, currency: notice.currency, payouts: noticed };
191
+ }
192
+
193
+ // ── db half ────────────────────────────────────────────────────────────────
194
+
195
+ function defaultPool() { return require('./pool').pool; }
196
+
197
+ const COLS = 'id, kind, rule_id, run_key, actor_id, actor_rank, session_id, session_source, recipient_id, amount_cents, currency, terms, note, created_at';
198
+
199
+ function shapeRow(r) {
200
+ if (!r) return null;
201
+ return {
202
+ ...r,
203
+ id: String(r.id),
204
+ rule_id: r.rule_id == null ? null : String(r.rule_id),
205
+ actor_id: String(r.actor_id),
206
+ recipient_id: r.recipient_id == null ? null : String(r.recipient_id),
207
+ session_id: r.session_id == null ? null : String(r.session_id),
208
+ amount_cents: r.amount_cents == null ? null : Number(r.amount_cents),
209
+ };
210
+ }
211
+
212
+ async function insertEvent(e, { pool = defaultPool() } = {}) {
213
+ const { rows } = await pool.query(
214
+ `INSERT INTO payout_approvals (kind, rule_id, run_key, actor_id, actor_rank, session_id, session_source,
215
+ recipient_id, amount_cents, currency, terms, note)
216
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)
217
+ RETURNING ${COLS}`,
218
+ [e.kind, e.rule_id ?? null, e.run_key ?? null, e.actor.id, e.actor.rank ?? null, e.actor.session_id ?? null,
219
+ e.actor.session_source ?? null, e.recipient_id ?? null, e.amount_cents ?? null, e.currency ?? null,
220
+ JSON.stringify(e.terms || {}), e.note ?? null],
221
+ );
222
+ return shapeRow(rows[0]);
223
+ }
224
+
225
+ async function ruleEvents(ruleId, { pool = defaultPool() } = {}) {
226
+ const { rows } = await pool.query(
227
+ `SELECT ${COLS} FROM payout_approvals WHERE id = $1 OR rule_id = $1 ORDER BY id`,
228
+ [ruleId],
229
+ );
230
+ return rows.map(shapeRow);
231
+ }
232
+
233
+ async function readRule(ruleId, opts = {}) {
234
+ const events = await ruleEvents(ruleId, opts);
235
+ return { rule: foldRule(events), events };
236
+ }
237
+
238
+ async function listRules({ pool = defaultPool() } = {}) {
239
+ const { rows } = await pool.query(
240
+ `SELECT ${COLS} FROM payout_approvals
241
+ WHERE kind IN ('rule_signed', 'rule_narrowed', 'rule_revoked')
242
+ ORDER BY id`,
243
+ );
244
+ const byRule = new Map();
245
+ for (const r of rows.map(shapeRow)) {
246
+ const id = r.kind === 'rule_signed' ? r.id : r.rule_id;
247
+ if (!byRule.has(id)) byRule.set(id, []);
248
+ byRule.get(id).push(r);
249
+ }
250
+ return [...byRule.values()].map(foldRule).filter(Boolean);
251
+ }
252
+
253
+ async function listLog({ ruleId = null, limit = 100, pool = defaultPool() } = {}) {
254
+ const lim = Math.min(Math.max(Number(limit) || 100, 1), 500);
255
+ const { rows } = ruleId
256
+ ? await pool.query(`SELECT ${COLS} FROM payout_approvals WHERE id = $1 OR rule_id = $1 ORDER BY id DESC LIMIT $2`, [ruleId, lim])
257
+ : await pool.query(`SELECT ${COLS} FROM payout_approvals ORDER BY id DESC LIMIT $1`, [lim]);
258
+ return rows.map(shapeRow);
259
+ }
260
+
261
+ // For M04 and friends: may this run under this rule pay now?
262
+ // The N10 gates (age tier, sanctions, open holds) are checked again at pay time,
263
+ // because a recipient's standing can change inside a pause window. Injectable so
264
+ // a test can stand in for the database; production reads ./payout-gates.
265
+ function gatesOf(opts) { return opts.gates || require('./payout-gates'); }
266
+
267
+ // Returns { ok, payouts, held }: `payouts` are the lines that may be paid now
268
+ // (each with pay_to — the guardian's account for a 16-17 recipient), `held` the
269
+ // lines that may not, each already notified with a hold the person can appeal.
270
+ // A run is never stopped whole for one recipient; their line waits, the rest pay.
271
+ async function checkRun({ ruleId, runKey, payouts, now = new Date() }, opts = {}) {
272
+ const { rule, events } = await readRule(ruleId, opts);
273
+ const v = judgeRun({ rule, ruleEvents: events, runKey, payouts, now });
274
+ if (!v.ok) return v;
275
+ const gates = gatesOf(opts);
276
+ // One batched read for the whole run, not a round of reads per recipient.
277
+ const owed = new Map();
278
+ for (const p of v.payouts) owed.set(String(p.recipient_id), (owed.get(String(p.recipient_id)) || 0) + Number(p.amount_cents));
279
+ const verdicts = await gates.gateOwedPayMany(owed, { now, pool: opts.pool });
280
+ const minors = [...verdicts].filter(([, g]) => g.ok && g.needs_guardian_cosign).map(([rid]) => rid);
281
+ const cosigned = minors.length ? await gates.cosignedInRun({ ruleId: v.rule_id, runKey, builderIds: minors }, { pool: opts.pool }) : new Set();
282
+ const pay = [];
283
+ const held = [];
284
+ for (const p of v.payouts) {
285
+ const g = verdicts.get(String(p.recipient_id));
286
+ if (!g.ok) { held.push({ ...p, reason: g.reason, freeze_id: g.freeze_id }); continue; }
287
+ if (g.needs_guardian_cosign && !cosigned.has(String(p.recipient_id))) { held.push({ ...p, reason: 'guardian_cosign_missing', freeze_id: null }); continue; }
288
+ pay.push({ ...p, pay_to: g.pay_to });
289
+ }
290
+ return { ...v, payouts: pay, held };
291
+ }
292
+
293
+ // For a one-off: does approval `id` cover exactly this payout, grant or contract?
294
+ // It answers whether the authority EXISTS; it does not spend it. The paying task
295
+ // must make the transfer idempotent on the approval id (one approval, one
296
+ // transfer: the processor idempotency key and its own money record, ADR 0387 D2),
297
+ // or a second call would pay twice. checkRun is the same, keyed on rule + run_key.
298
+ async function checkApproval({ id, kind = 'payout_approved', recipientId, amountCents = null, currency = null, now = new Date() }, opts = {}) {
299
+ const pool = opts.pool || defaultPool();
300
+ if (!isId(id)) return { ok: false, reason: 'no_such_approval' };
301
+ const { rows } = await pool.query(`SELECT ${COLS} FROM payout_approvals WHERE id = $1`, [id]);
302
+ const row = shapeRow(rows[0]);
303
+ if (!row || row.kind !== kind) return { ok: false, reason: 'no_such_approval' };
304
+ if (String(row.recipient_id) !== String(recipientId)) return { ok: false, reason: 'different_recipient' };
305
+ if (kind === 'payout_approved' && (row.amount_cents !== Number(amountCents) || row.currency !== currency)) {
306
+ return { ok: false, reason: 'different_amount' };
307
+ }
308
+ // N10: the recipient must still pass the gates when it pays. Money owed and
309
+ // refused files a hold that tells them; a grant or contract refuses without one.
310
+ const gates = gatesOf(opts);
311
+ const g = kind === 'payout_approved'
312
+ ? await gates.gateOwedPay(row.recipient_id, { owedCents: row.amount_cents, now, pool })
313
+ : await gates.gateRecipient(row.recipient_id, { now, pool });
314
+ if (!g.ok) return { ok: false, reason: 'recipient_gated', gate: { reason: g.reason, freeze_id: g.freeze_id || null } };
315
+ if (g.needs_guardian_cosign && !(await gates.hasCosign({ builderId: row.recipient_id, approvalId: row.id }, { pool }))) {
316
+ return { ok: false, reason: 'guardian_cosign_missing' };
317
+ }
318
+ return { ok: true, approval: row, pay_to: g.pay_to };
319
+ }
320
+
321
+ module.exports = {
322
+ FORMULA_KINDS,
323
+ SCHEDULES,
324
+ CAP_KEYS,
325
+ SIGNED_KINDS,
326
+ RUN_KEY_RE,
327
+ MAX_PAUSE_HOURS,
328
+ isCents,
329
+ isId,
330
+ cleanNote,
331
+ validateRuleTerms,
332
+ wideningReasons,
333
+ personalSession,
334
+ foldRule,
335
+ judgeRun,
336
+ insertEvent,
337
+ readRule,
338
+ listRules,
339
+ listLog,
340
+ checkRun,
341
+ checkApproval,
342
+ };