@bongos/core 1.23.21 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (457) hide show
  1. package/.bongos-core.json +983 -428
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +6 -3
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +94 -0
  27. package/clients/bongos-client/index.cjs +94 -0
  28. package/clients/bongos-client/index.d.ts +154 -4
  29. package/clients/bongos-client/index.mjs +94 -0
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
  31. package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
  32. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  33. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  34. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  35. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  36. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  37. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  38. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  39. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  40. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  41. package/docs/adr/README.md +10 -1
  42. package/docs/api/openapi.json +8619 -5885
  43. package/docs/api-reference.md +68 -9
  44. package/docs/architecture.md +37 -1
  45. package/docs/canonical-permissions.md +1 -1
  46. package/docs/copy-inventory.md +420 -337
  47. package/docs/copy-registry.json +1321 -500
  48. package/docs/file-map.md +4 -0
  49. package/docs/module-api-changelog.md +7 -0
  50. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  51. package/docs/onboarding/diagrams/README.md +1 -1
  52. package/docs/onboarding/drachmae-and-karma.md +15 -15
  53. package/docs/onboarding/primer.md +2 -2
  54. package/docs/page-inventory.json +55 -6
  55. package/docs/page-readings.json +1185 -1011
  56. package/docs/recipes/google-sign-in.md +60 -0
  57. package/docs/recipes/ops-gotchas.md +1 -1
  58. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  59. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  60. package/migrations/core_283_payout_approvals.sql +183 -0
  61. package/migrations/core_284_payout_gates.sql +179 -0
  62. package/migrations/core_285_task_needs.sql +74 -0
  63. package/modules/ai-studio/CLAUDE.md +15 -0
  64. package/modules/ai-studio/billing.js +67 -0
  65. package/modules/ai-studio/context.js +29 -0
  66. package/modules/ai-studio/db.js +172 -0
  67. package/modules/ai-studio/gemini.js +75 -0
  68. package/modules/ai-studio/generate.js +119 -0
  69. package/modules/ai-studio/keys.js +73 -0
  70. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  71. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  72. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  73. package/modules/ai-studio/module.json +26 -0
  74. package/modules/ai-studio/openrouter.js +90 -0
  75. package/modules/ai-studio/payments.js +105 -0
  76. package/modules/ai-studio/pricing.js +92 -0
  77. package/modules/ai-studio/routes/ai-studio.js +252 -0
  78. package/modules/ai-studio/studio.js +121 -0
  79. package/modules/autonomy/fence.js +64 -1
  80. package/modules/builder-settings/builder-needs.js +27 -1
  81. package/modules/copy-desk/routes/copy-desk.js +5 -0
  82. package/modules/economy/achievements.js +22 -10
  83. package/modules/economy/cost-classify.js +9 -0
  84. package/modules/economy/cost-write-guard.js +52 -0
  85. package/modules/economy/cost.js +2 -1
  86. package/modules/economy/credits.js +18 -15
  87. package/modules/economy/goal-reward.js +161 -0
  88. package/modules/economy/reward.js +4 -0
  89. package/modules/economy/routes/cost.js +33 -7
  90. package/modules/government/catalog.js +6 -0
  91. package/modules/government/db.js +108 -0
  92. package/modules/government/docket.js +36 -0
  93. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  94. package/modules/government/pollers/board-expiry.js +108 -20
  95. package/modules/government/protected-surfaces.json +1 -0
  96. package/modules/government/routes/government.js +50 -18
  97. package/modules/government/routing.js +39 -5
  98. package/modules/government/waiting.js +74 -0
  99. package/modules/grading/grader-subagent.js +30 -11
  100. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  101. package/modules/hall-ui/public/ai-studio.css +193 -0
  102. package/modules/hall-ui/public/ai-studio.html +98 -0
  103. package/modules/hall-ui/public/ai-studio.js +556 -0
  104. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  105. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  106. package/modules/hall-ui/public/blockers-lib.js +15 -1
  107. package/modules/hall-ui/public/blockers.html +8 -0
  108. package/modules/hall-ui/public/blockers.js +1 -1
  109. package/modules/hall-ui/public/city.html +15 -15
  110. package/modules/hall-ui/public/claim-action.js +3 -0
  111. package/modules/hall-ui/public/collab.html +12 -0
  112. package/modules/hall-ui/public/craft-mode.js +4 -1
  113. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  114. package/modules/hall-ui/public/drachmae.html +1 -1
  115. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  120. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  121. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  122. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  123. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  124. package/modules/hall-ui/public/goal-inbox.js +1 -1
  125. package/modules/hall-ui/public/goals-render.js +4 -0
  126. package/modules/hall-ui/public/goals.html +8 -0
  127. package/modules/hall-ui/public/gov-living.js +6 -2
  128. package/modules/hall-ui/public/hall-ask.css +5 -0
  129. package/modules/hall-ui/public/hall-ask.js +39 -3
  130. package/modules/hall-ui/public/notes-panel.css +20 -0
  131. package/modules/hall-ui/public/notes-panel.js +146 -0
  132. package/modules/hall-ui/public/project-settings.html +1 -1
  133. package/modules/hall-ui/public/project-settings.js +7 -5
  134. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  135. package/modules/hall-ui/public/settings.html +15 -0
  136. package/modules/hall-ui/public/street-deck.js +12 -3
  137. package/modules/hall-ui/public/studio.css +90 -8
  138. package/modules/hall-ui/public/studio.html +39 -10
  139. package/modules/hall-ui/public/studio.js +148 -18
  140. package/modules/hall-ui/public/task.html +2 -0
  141. package/modules/hall-ui/public/task.js +4 -0
  142. package/modules/hall-ui/public/tweak-editor.js +12 -3
  143. package/modules/hall-ui/public/welcome-boot.css +42 -6
  144. package/modules/hall-ui/public/welcome-boot.js +20 -6
  145. package/modules/hall-ui/public/work.js +20 -0
  146. package/modules/hall-ui/records/ai-studio.md +36 -0
  147. package/modules/hall-ui/records/blockers-people.md +24 -0
  148. package/modules/lifecycle/db-analytics.js +10 -3
  149. package/modules/lifecycle/db-claims.js +32 -4
  150. package/modules/lifecycle/db-goals.js +48 -18
  151. package/modules/lifecycle/db-shared.js +9 -0
  152. package/modules/lifecycle/db-tasks.js +15 -6
  153. package/modules/lifecycle/db-versions.js +21 -8
  154. package/modules/lifecycle/done-when.js +23 -1
  155. package/modules/lifecycle/lifecycle.js +5 -0
  156. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  157. package/modules/lifecycle/module.json +2 -1
  158. package/modules/lifecycle/notes.js +219 -0
  159. package/modules/lifecycle/page-notice.js +124 -0
  160. package/modules/lifecycle/page-tweak-artist.js +18 -1
  161. package/modules/lifecycle/publish-reconciler.js +67 -20
  162. package/modules/lifecycle/routes/claims.js +5 -2
  163. package/modules/lifecycle/routes/goals.js +3 -1
  164. package/modules/lifecycle/routes/notes.js +91 -0
  165. package/modules/lifecycle/routes/project-settings.js +43 -35
  166. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  167. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  168. package/modules/lifecycle/routes/tasks.js +12 -12
  169. package/modules/lifecycle/ship-assurance.js +4 -1
  170. package/modules/lifecycle/ship-card.js +8 -2
  171. package/modules/lifecycle/task-needs-gate.js +70 -0
  172. package/modules/market-watch/CLAUDE.md +15 -0
  173. package/modules/market-watch/db.js +340 -0
  174. package/modules/market-watch/key.js +58 -0
  175. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  176. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  177. package/modules/market-watch/module.json +26 -0
  178. package/modules/market-watch/pollers/hub-seed.js +54 -0
  179. package/modules/market-watch/pollers/weekly-run.js +100 -0
  180. package/modules/market-watch/research.js +350 -0
  181. package/modules/market-watch/run.js +245 -0
  182. package/modules/market-watch/seed/market-data.json +38 -0
  183. package/modules/market-watch/seed/partners-data.json +497 -0
  184. package/modules/market-watch/seed.js +252 -0
  185. package/modules/market-watch/store.js +230 -0
  186. package/modules/memory/routes/memory.js +14 -2
  187. package/modules/memory/routes/search.js +8 -2
  188. package/modules/onboarding/howto-moments.js +8 -1
  189. package/modules/platform-identity/account-logins.js +192 -0
  190. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  191. package/modules/platform-identity/platform-identity.js +5 -4
  192. package/modules/platform-identity/routes/my-projects.js +6 -0
  193. package/modules/platform-identity/routes/profile.js +18 -0
  194. package/modules/platform-identity/routes/public-profile.js +10 -1
  195. package/modules/platform-identity/routes/sso.js +26 -1
  196. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  197. package/modules/provisioning/credential-preflight.js +13 -3
  198. package/modules/provisioning/pollers/app-liveness.js +25 -5
  199. package/modules/provisioning/routes/provisioning.js +1 -1
  200. package/modules/provisioning/starter-bundles.js +8 -2
  201. package/modules/public-landing/public/account.html +10 -2
  202. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  203. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  205. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  209. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  210. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  213. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  214. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  221. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  222. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  223. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  224. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  225. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  226. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  227. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  228. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  229. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  230. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  233. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  244. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  245. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  246. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  247. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  248. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  249. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  250. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  251. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  252. package/modules/public-landing/public/community.html +1 -1
  253. package/modules/public-landing/public/connections.html +2 -2
  254. package/modules/public-landing/public/guild.html +1 -1
  255. package/modules/public-landing/public/invitations.html +2 -2
  256. package/modules/public-landing/public/projects.html +5 -5
  257. package/modules/raise/views/nda.html +2 -0
  258. package/modules/specialities/db.js +32 -1
  259. package/modules/specialities/routes/specialities.js +10 -6
  260. package/modules/status-ui/public/index.html +1 -1
  261. package/package-lock.json +2 -2
  262. package/package.json +1 -1
  263. package/release-notes.json +286 -0
  264. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  265. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  266. package/scripts/bongos/claim.js +22 -1
  267. package/scripts/bongos/db-backup-nightly.sh +29 -0
  268. package/scripts/bongos/deploy-divergence.js +7 -0
  269. package/scripts/bongos/doc-cli-guard.js +6 -0
  270. package/scripts/bongos/fitness.js +3 -2
  271. package/scripts/bongos/gen-diagrams.js +3 -3
  272. package/scripts/bongos/lib.sh +4 -5
  273. package/scripts/bongos/migration-namespace.js +13 -1
  274. package/scripts/bongos/plain-cards.js +4 -0
  275. package/scripts/bongos/provision-github-setup.js +3 -1
  276. package/scripts/bongos/provision-units.js +6 -1
  277. package/scripts/bongos/publish-manifest.js +4 -0
  278. package/scripts/bongos/release.js +2 -0
  279. package/scripts/bongos/run-unit-tests.js +268 -82
  280. package/scripts/bongos/seed-tasks.example.json +56 -0
  281. package/scripts/bongos/seed-tasks.js +222 -0
  282. package/scripts/bongos/seed-verify-criteria.js +1 -2
  283. package/scripts/bongos/ship-finish.js +2 -0
  284. package/scripts/bongos/ship-resume.js +2 -0
  285. package/scripts/bongos/strand-lease.js +250 -0
  286. package/scripts/bongos/strand-watch.js +129 -12
  287. package/scripts/bongos/version-literal-guard.js +2 -2
  288. package/scripts/discord/post.js +4 -3
  289. package/scripts/gds/seed-tasks.js +7 -0
  290. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  291. package/scripts/hall-preview/server.js +21 -0
  292. package/src/bongos/auth-config.js +29 -1
  293. package/src/bongos/auth-discord.js +3 -3
  294. package/src/bongos/auth-github.js +3 -6
  295. package/src/bongos/auth-google.js +217 -0
  296. package/src/bongos/device-approval.js +140 -0
  297. package/src/bongos/llm-pricing.js +17 -1
  298. package/src/bongos/metrics.js +48 -2
  299. package/src/bongos/middleware/rate-limit.js +30 -11
  300. package/src/bongos/module-scope-map.js +16 -1
  301. package/src/bongos/optimizer.js +15 -1
  302. package/src/bongos/payout-approvals.js +342 -0
  303. package/src/bongos/payout-gates.js +364 -0
  304. package/src/bongos/prelaunch-gate.js +7 -9
  305. package/src/bongos/prelaunch-switch.js +20 -0
  306. package/src/bongos/route-rank-check.js +3 -0
  307. package/src/bongos/routes/auth-google.js +246 -0
  308. package/src/bongos/routes/auth.js +38 -1
  309. package/src/bongos/routes/backup.js +33 -9
  310. package/src/bongos/routes/builders.js +57 -30
  311. package/src/bongos/routes/github.js +9 -0
  312. package/src/bongos/routes/me.js +18 -0
  313. package/src/bongos/routes/my-payouts.js +133 -0
  314. package/src/bongos/routes/payout-approvals.js +312 -0
  315. package/src/bongos/routes/payout-gates.js +179 -0
  316. package/src/bongos/routes.js +15 -1
  317. package/src/bongos/secret-box.js +18 -3
  318. package/src/bongos/serve-internal.js +47 -19
  319. package/src/bongos/sign-in-pages.js +138 -0
  320. package/src/bongos/task-needs.js +146 -0
  321. package/src/bongos-downloads.js +18 -1
  322. package/src/instance-config.js +28 -0
  323. package/src/module-api.js +14 -1
  324. package/src/platform-server.js +27 -1
  325. package/tests/account_logins.mjs +326 -0
  326. package/tests/account_logins_db.mjs +161 -0
  327. package/tests/ai_studio_billing.mjs +253 -0
  328. package/tests/ai_studio_generate.mjs +282 -0
  329. package/tests/ai_studio_keys.mjs +159 -0
  330. package/tests/ai_studio_requests.mjs +319 -0
  331. package/tests/auth_backchannel_logout_route.mjs +201 -0
  332. package/tests/auto_satisfy_criteria.mjs +10 -1
  333. package/tests/autobongos_tweak_lane.mjs +144 -0
  334. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  335. package/tests/backfill_subagent_rewards.mjs +17 -1
  336. package/tests/backup_trigger_lock.mjs +119 -0
  337. package/tests/batched_writes_parity.mjs +340 -0
  338. package/tests/blockers_people.mjs +198 -0
  339. package/tests/bounded_rate_maps.mjs +184 -0
  340. package/tests/claimable_blocker_status.mjs +1 -1
  341. package/tests/claims_batch_routes.mjs +421 -0
  342. package/tests/conductor_main_e2e.mjs +4 -0
  343. package/tests/config_read_cache.mjs +171 -0
  344. package/tests/copy_desk_page_editor.mjs +12 -0
  345. package/tests/cost_write_guard.mjs +200 -0
  346. package/tests/craft_achievements.mjs +33 -10
  347. package/tests/criterion_goal_attach.mjs +1 -1
  348. package/tests/db_backup_nightly.mjs +73 -1
  349. package/tests/deploy_divergence_line.mjs +7 -0
  350. package/tests/effective_visibility_predicate.mjs +12 -0
  351. package/tests/fitness.mjs +19 -0
  352. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  353. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  354. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  355. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  356. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  357. package/tests/github_one_trip.mjs +34 -0
  358. package/tests/github_repo_visibility.mjs +20 -0
  359. package/tests/goal_achievement.mjs +30 -1
  360. package/tests/goal_achievement_reward.mjs +205 -0
  361. package/tests/goal_archive_disposition.mjs +5 -5
  362. package/tests/goal_sort_staleness.mjs +2 -1
  363. package/tests/goal_tier.mjs +11 -1
  364. package/tests/google_fonts_nonblocking.mjs +84 -0
  365. package/tests/google_sign_in.mjs +401 -0
  366. package/tests/government_board_expiry.mjs +94 -0
  367. package/tests/government_protected_surfaces.mjs +1 -1
  368. package/tests/government_routes.mjs +146 -23
  369. package/tests/grader.mjs +31 -0
  370. package/tests/hall_ai_studio.mjs +347 -0
  371. package/tests/hall_city.mjs +8 -0
  372. package/tests/hall_page_gate_map.mjs +3 -0
  373. package/tests/hall_studio_home.mjs +61 -2
  374. package/tests/home_attention_cards.mjs +1 -1
  375. package/tests/hook_shell_tools.mjs +8 -0
  376. package/tests/hooks_drain.mjs +1 -1
  377. package/tests/host_topology_skips.mjs +2 -0
  378. package/tests/howto_moments.mjs +17 -0
  379. package/tests/human_signal.mjs +141 -0
  380. package/tests/install_script_reader.mjs +51 -0
  381. package/tests/integration_fixture_ids.mjs +82 -0
  382. package/tests/invite_expiry_db.mjs +13 -1
  383. package/tests/lib_sh_resolution.mjs +0 -4
  384. package/tests/market_watch_data.mjs +244 -0
  385. package/tests/market_watch_data_db.mjs +308 -0
  386. package/tests/market_watch_module.mjs +114 -0
  387. package/tests/market_watch_run.mjs +503 -0
  388. package/tests/metrics.mjs +124 -0
  389. package/tests/migration_namespace.mjs +14 -0
  390. package/tests/module_api.mjs +2 -0
  391. package/tests/module_loader.mjs +1 -1
  392. package/tests/module_skills_hook.mjs +69 -0
  393. package/tests/notes.mjs +182 -0
  394. package/tests/own_scope_guard.mjs +5 -0
  395. package/tests/page_tweak_marker_sql.mjs +76 -0
  396. package/tests/page_tweak_no_block.mjs +135 -0
  397. package/tests/page_tweak_notice.mjs +103 -0
  398. package/tests/payout_approvals.mjs +253 -0
  399. package/tests/payout_approvals_db.mjs +106 -0
  400. package/tests/payout_gates.mjs +322 -0
  401. package/tests/payout_gates_db.mjs +113 -0
  402. package/tests/planning_session_skill.mjs +13 -3
  403. package/tests/project_settings_page.mjs +29 -5
  404. package/tests/project_settings_spend_cap_route.mjs +235 -0
  405. package/tests/promotion_sweep.mjs +29 -1
  406. package/tests/provision.mjs +3 -2
  407. package/tests/provisioning_app_status.mjs +18 -0
  408. package/tests/provisioning_credential_preflight.mjs +53 -0
  409. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  410. package/tests/public_landing_projects.mjs +37 -0
  411. package/tests/publish_reconciler_overlap.mjs +60 -0
  412. package/tests/rank_change_broadcast_port.mjs +151 -0
  413. package/tests/seed_tasks.mjs +176 -0
  414. package/tests/seed_verify_criteria.mjs +2 -6
  415. package/tests/session_start_freshness.mjs +66 -1
  416. package/tests/shell_hooks.mjs +209 -0
  417. package/tests/ship_assurance.mjs +28 -0
  418. package/tests/ship_card.mjs +20 -5
  419. package/tests/site_classified_once.mjs +142 -0
  420. package/tests/speciality_db_shape.mjs +23 -0
  421. package/tests/speciality_session_skills.mjs +48 -2
  422. package/tests/strand_lease.mjs +249 -0
  423. package/tests/strand_watch.mjs +58 -0
  424. package/tests/task_criteria_routes.mjs +275 -0
  425. package/tests/task_criteria_unlink_db.mjs +123 -0
  426. package/tests/task_needs.mjs +233 -0
  427. package/tests/task_needs_db.mjs +110 -0
  428. package/tests/unit_runner_serial.mjs +266 -43
  429. package/tests/version_close_route.mjs +1 -1
  430. package/tests/version_literals.mjs +5 -7
  431. package/tests/welcome_boot.mjs +16 -5
  432. package/tests/welcome_boot_browser.mjs +36 -4
  433. package/docs/recipes/bongos-cli-release.md +0 -66
  434. package/scripts/bongos/backfill-disciplines.js +0 -144
  435. package/scripts/bongos/backfill-session-earnings.js +0 -123
  436. package/scripts/bongos/backfill-session-types.js +0 -87
  437. package/scripts/bongos/build-bongos.js +0 -94
  438. package/scripts/bongos/cleanup-orphans.js +0 -74
  439. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  440. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  441. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  442. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  443. package/scripts/bongos/smoke-batch-release.sh +0 -316
  444. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  445. package/scripts/bongos/smoke-dependencies.sh +0 -143
  446. package/scripts/gds/backfill-disciplines.js +0 -7
  447. package/scripts/gds/backfill-session-earnings.js +0 -7
  448. package/scripts/gds/backfill-session-types.js +0 -7
  449. package/scripts/gds/cleanup-orphans.js +0 -7
  450. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  451. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  452. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  453. package/scripts/gds/smoke-batch-claim.sh +0 -8
  454. package/scripts/gds/smoke-batch-release.sh +0 -8
  455. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  456. package/scripts/gds/smoke-dependencies.sh +0 -8
  457. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -0,0 +1,340 @@
1
+ // tests/batched_writes_parity.mjs — the batched goal archive, version close and
2
+ // weekly karma tally give the SAME results as the one-query-per-row loops they
3
+ // replaced (task 1004929).
4
+ //
5
+ // Each case runs the old loop (tests/fixtures/per-row-loops-legacy.cjs, a
6
+ // verbatim copy) and the new code over the SAME seeded fake database, then
7
+ // compares what each returned or threw AND what each left in the tables. The
8
+ // fake is a small interpreter for exactly the statements both versions send —
9
+ // old per-row reads and new batched reads answer from one store — and it hands
10
+ // bigint ids back as STRINGS, as node-pg does, so a plan that carries numbers is
11
+ // matched back to rows the way production matches them. BEGIN snapshots the
12
+ // store and ROLLBACK restores it, so a thrown run is compared on what survived.
13
+ //
14
+ // The rank-set apply parity lives in tests/government_routes.mjs, where the real
15
+ // router is already standing.
16
+ //
17
+ // Run: node --test tests/batched_writes_parity.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const { archiveGoalWithDispositions, closeVersion } = require('../modules/lifecycle/db.js');
26
+ const { tallyPeerVotes } = require('../modules/lifecycle/db-analytics.js');
27
+ const { idKey } = require('../modules/lifecycle/db-shared.js');
28
+ const pgPool = require('../src/bongos/pool').pool;
29
+ const legacy = require('./fixtures/per-row-loops-legacy.cjs');
30
+
31
+ const TERMINAL = new Set(['shipped', 'abandoned']);
32
+ const same = (a, b) => String(a) === String(b);
33
+ const inIds = (arr, v) => arr.some((x) => same(idKey(x), idKey(v)));
34
+ const clone = (v) => JSON.parse(JSON.stringify(v));
35
+
36
+ // ---- the fake database -------------------------------------------------------
37
+
38
+ function fakeDb(seed) {
39
+ let st = clone(seed);
40
+ let snap = null;
41
+ const queries = [];
42
+ const task = (id) => st.tasks.find((t) => same(t.id, idKey(id)));
43
+ const goal = (id) => st.goals.find((g) => same(g.id, idKey(id)));
44
+ const out = (row) => (row ? { ...row, id: String(row.id) } : row);
45
+ const openWork = (t) => !TERMINAL.has(t.status) && !t.title.startsWith('__smoke__');
46
+
47
+ async function query(sql, p = []) {
48
+ const s = String(sql).replace(/\s+/g, ' ').trim();
49
+ queries.push(s);
50
+ if (s === 'BEGIN') { snap = clone(st); return { rows: [] }; }
51
+ if (s === 'COMMIT') { snap = null; return { rows: [] }; }
52
+ if (s === 'ROLLBACK') { if (snap) st = snap; snap = null; return { rows: [] }; }
53
+
54
+ // ---- goal archive -------------------------------------------------------
55
+ if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map(out) };
56
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE$/.test(s)) {
57
+ const t = task(p[0]);
58
+ return { rows: t && same(t.goal_id, p[1]) ? [out(t)] : [] };
59
+ }
60
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
61
+ return { rows: st.tasks.filter((t) => inIds(p[0], t.id) && same(t.goal_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
62
+ }
63
+ if (/^SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL LIMIT 1$/.test(s)) {
64
+ return { rows: st.claims.filter((c) => same(c.task_id, idKey(p[0])) && !c.released_at).slice(0, 1).map((c) => ({ id: String(c.id) })) };
65
+ }
66
+ if (/^SELECT DISTINCT task_id FROM claims WHERE task_id = ANY\(\$1::bigint\[\]\) AND released_at IS NULL$/.test(s)) {
67
+ const ids = [...new Set(st.claims.filter((c) => inIds(p[0], c.task_id) && !c.released_at).map((c) => String(c.task_id)))];
68
+ return { rows: ids.map((task_id) => ({ task_id })) };
69
+ }
70
+ if (/^UPDATE tasks SET status = 'abandoned', .* WHERE id = \$1$/.test(s)) {
71
+ const t = task(p[0]); t.status = 'abandoned'; if (p[1]) t.value_summary = p[1];
72
+ return { rows: [], rowCount: 1 };
73
+ }
74
+ if (/^SELECT id, status FROM goals WHERE id = \$1 FOR SHARE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map((g) => ({ id: String(g.id), status: g.status })) };
75
+ if (/^UPDATE tasks SET goal_id = \$2, updated_at = now\(\) WHERE id = \$1$/.test(s)) {
76
+ task(p[0]).goal_id = Number(p[1]);
77
+ return { rows: [], rowCount: 1 };
78
+ }
79
+ if (/count\(\*\)::int AS n FROM (tasks|goals)/.test(s)) return { rows: [{ n: 0 }] }; // the re-counts are not under test
80
+ if (/^UPDATE goal_membership_requests/.test(s)) return { rows: [], rowCount: 0 };
81
+
82
+ // ---- version close ------------------------------------------------------
83
+ if (/FROM versions WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [{ id: p[0], status: 'building' }] };
84
+ if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE$/.test(s)) {
85
+ const g = goal(p[0]);
86
+ return { rows: g && same(g.version_id, p[1]) ? [out(g)] : [] };
87
+ }
88
+ if (/FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
89
+ return { rows: st.goals.filter((g) => inIds(p[0], g.id) && same(g.version_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
90
+ }
91
+ if (/^UPDATE tasks SET status = 'abandoned', .* WHERE goal_id = \$1 AND .* RETURNING id$/.test(s)) {
92
+ const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
93
+ for (const t of hit) { t.status = 'abandoned'; t.value_summary = p[1]; }
94
+ return { rows: hit.map((t) => ({ id: String(t.id) })) };
95
+ }
96
+ if (/^UPDATE goals SET status = 'archived', updated_at = now\(\) WHERE id = \$1/.test(s)) {
97
+ const g = goal(p[0]); g.status = 'archived';
98
+ return { rows: [out(g)], rowCount: 1 };
99
+ }
100
+ if (/^WITH next_order AS .* INSERT INTO goals/.test(s)) {
101
+ const src = goal(p[0]);
102
+ const id = st.nextGoalId++;
103
+ st.goals.push({ id, version_id: p[1], title: src.title, status: 'open', succeeded_by_goal_id: null });
104
+ return { rows: [{ id: String(id) }] };
105
+ }
106
+ if (/^INSERT INTO goal_members/.test(s)) return { rows: [], rowCount: 0 };
107
+ if (/^UPDATE tasks SET goal_id = \$2, version_id = \$3, .* RETURNING id$/.test(s)) {
108
+ const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
109
+ for (const t of hit) { t.goal_id = Number(p[1]); t.version_id = p[2]; }
110
+ return { rows: hit.map((t) => ({ id: String(t.id) })) };
111
+ }
112
+ if (/^UPDATE goals SET succeeded_by_goal_id = \$2/.test(s)) { goal(p[0]).succeeded_by_goal_id = Number(p[1]); return { rows: [] }; }
113
+ if (/FROM tasks t LEFT JOIN goals g/.test(s)) return { rows: [] };
114
+ if (/^UPDATE versions SET status = 'shipped'/.test(s)) return { rows: [{ id: p[0], status: 'shipped' }] };
115
+
116
+ // ---- weekly karma tally -------------------------------------------------
117
+ if (/FROM peer_votes pv JOIN tasks t/.test(s)) {
118
+ return { rows: st.peerVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
119
+ }
120
+ if (/to_regclass\('ideas_votes'\)/.test(s)) return { rows: [{ has: st.ideaVotes !== null }] };
121
+ if (/FROM ideas_votes iv JOIN idea_inbox i/.test(s)) {
122
+ return { rows: st.ideaVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
123
+ }
124
+ if (/^INSERT INTO karma_log .* VALUES \(\$1, \$2, 'peer_vote_weekly', NULL\)$/.test(s)) {
125
+ st.karma.push({ builder_id: String(p[0]), delta: Number(p[1]), reason: 'peer_vote_weekly', granted_by: null });
126
+ return { rows: [], rowCount: 1 };
127
+ }
128
+ if (/^INSERT INTO karma_log .* FROM unnest\(\$1::bigint\[\], \$2::int\[\]\) WITH ORDINALITY .* ORDER BY k\.ord$/.test(s)) {
129
+ p[0].forEach((b, i) => st.karma.push({ builder_id: String(b), delta: Number(p[1][i]), reason: 'peer_vote_weekly', granted_by: null }));
130
+ return { rows: [], rowCount: p[0].length };
131
+ }
132
+ if (/^UPDATE (peer_votes|ideas_votes) SET tallied_at = now\(\) WHERE id = ANY/.test(s)) {
133
+ const list = s.startsWith('UPDATE peer_votes') ? st.peerVotes : st.ideaVotes;
134
+ for (const v of list) if (inIds(p[0], v.id)) v.tallied = true;
135
+ return { rows: [], rowCount: p[0].length };
136
+ }
137
+
138
+ throw new Error(`fake db: unhandled statement: ${s.slice(0, 120)}`);
139
+ }
140
+ const client = { query, release() {} };
141
+ return {
142
+ pool: { connect: async () => client, query },
143
+ client,
144
+ queries,
145
+ get state() { return clone(st); },
146
+ };
147
+ }
148
+
149
+ // Run a legacy loop the way its function ran it: inside one transaction, rolled
150
+ // back on a throw. Returns { result } or { error }.
151
+ async function runLegacyTx(db, fn) {
152
+ await db.client.query('BEGIN');
153
+ try {
154
+ const result = await fn(db.client);
155
+ await db.client.query('COMMIT');
156
+ return { result };
157
+ } catch (error) {
158
+ await db.client.query('ROLLBACK');
159
+ return { error };
160
+ }
161
+ }
162
+ async function runNew(fn) {
163
+ try { return { result: await fn() }; } catch (error) { return { error }; }
164
+ }
165
+ const errShape = (e) => (e ? { code: e.code, taskId: e.taskId, taskTitle: e.taskTitle, goalId: e.goalId, toGoalId: e.toGoalId, targetStatus: e.targetStatus } : null);
166
+
167
+ // ---- goal archive ------------------------------------------------------------
168
+
169
+ const ARCHIVE_SEED = {
170
+ goals: [
171
+ { id: 7, version_id: 'V1', title: 'the goal being archived', status: 'open' },
172
+ { id: 8, version_id: 'V1', title: 'a neighbour', status: 'open' },
173
+ { id: 9, version_id: 'V1', title: 'an open move target', status: 'open' },
174
+ { id: 10, version_id: 'V1', title: 'a closed move target', status: 'archived' },
175
+ ],
176
+ tasks: [
177
+ { id: 1, goal_id: 7, version_id: 'V1', status: 'ready', title: 'one', value_summary: null },
178
+ { id: 2, goal_id: 7, version_id: 'V1', status: 'shipped', title: 'two', value_summary: null },
179
+ { id: 3, goal_id: 8, version_id: 'V1', status: 'ready', title: 'three (elsewhere)', value_summary: null },
180
+ { id: 4, goal_id: 7, version_id: 'V1', status: 'active', title: 'four (claimed)', value_summary: null },
181
+ { id: 5, goal_id: 7, version_id: 'V1', status: 'backlog', title: 'five', value_summary: null },
182
+ { id: 6, goal_id: 7, version_id: 'V1', status: 'ready', title: 'six', value_summary: null },
183
+ ],
184
+ claims: [{ id: 99, task_id: 4, released_at: null }, { id: 98, task_id: 6, released_at: '2026-01-01' }],
185
+ nextGoalId: 100,
186
+ };
187
+
188
+ const ARCHIVE_PLANS = {
189
+ 'mixed verbs, skips and a move': [
190
+ { taskId: 1, verb: 'abandon' }, { taskId: 2, verb: 'abandon' }, { taskId: 3, verb: 'abandon' },
191
+ { taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 6, verb: 'abandon' },
192
+ ],
193
+ 'plan order is not id order': [{ taskId: 6, verb: 'abandon' }, { taskId: 1, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' }],
194
+ 'a task named twice reads what the first step left': [
195
+ { taskId: 1, verb: 'abandon' }, { taskId: 1, verb: 'abandon' },
196
+ { taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' },
197
+ ],
198
+ 'ids as strings, as a JSON body carries them': [{ taskId: '1', verb: 'abandon' }, { taskId: '5', verb: 'move', toGoalId: '9' }],
199
+ 'an actively claimed task stops it, after earlier steps': [{ taskId: 1, verb: 'abandon' }, { taskId: 4, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
200
+ 'a closed move target stops it': [{ taskId: 1, verb: 'abandon' }, { taskId: 5, verb: 'move', toGoalId: 10 }],
201
+ 'a missing move target stops it': [{ taskId: 5, verb: 'move', toGoalId: 404 }],
202
+ 'a task that does not exist is skipped': [{ taskId: 777, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
203
+ 'an empty plan': [],
204
+ };
205
+
206
+ for (const [name, plan] of Object.entries(ARCHIVE_PLANS)) {
207
+ test(`goal archive, old loop vs batched: ${name}`, async () => {
208
+ const args = { goalId: 7, plan, reason: 'cut in review' };
209
+ const oldDb = fakeDb(ARCHIVE_SEED);
210
+ const old = await runLegacyTx(oldDb, (c) => legacy.legacyArchiveLoop(c, args));
211
+ const neuDb = fakeDb(ARCHIVE_SEED);
212
+ const neu = await runNew(() => archiveGoalWithDispositions(args, { pool: neuDb.pool }));
213
+
214
+ assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal, naming the same task');
215
+ if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
216
+ assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks left behind');
217
+ });
218
+ }
219
+
220
+ test('goal archive reads every plan task in ONE locked statement and the claims in one more', async () => {
221
+ const db = fakeDb(ARCHIVE_SEED);
222
+ await archiveGoalWithDispositions({ goalId: 7, plan: ARCHIVE_PLANS['mixed verbs, skips and a move'], reason: 'r' }, { pool: db.pool });
223
+ assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = ANY/.test(q)).length, 1);
224
+ assert.equal(db.queries.filter((q) => /FROM claims WHERE task_id/.test(q)).length, 1);
225
+ assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = \$1/.test(q)).length, 0, 'no per-step read is left');
226
+ });
227
+
228
+ // ---- version close -----------------------------------------------------------
229
+
230
+ const CLOSE_SEED = {
231
+ goals: [
232
+ { id: 1, version_id: 'V1', title: 'g1', status: 'open', succeeded_by_goal_id: null },
233
+ { id: 2, version_id: 'V1', title: 'g2', status: 'open', succeeded_by_goal_id: null },
234
+ { id: 3, version_id: 'V1', title: 'g3 (already closed)', status: 'archived', succeeded_by_goal_id: null },
235
+ { id: 4, version_id: 'V9', title: 'g4 (another version)', status: 'open', succeeded_by_goal_id: null },
236
+ { id: 5, version_id: 'V1', title: 'g5', status: 'open', succeeded_by_goal_id: null },
237
+ ],
238
+ tasks: [
239
+ { id: 11, goal_id: 1, version_id: 'V1', status: 'ready', title: 'a', value_summary: null },
240
+ { id: 12, goal_id: 1, version_id: 'V1', status: 'shipped', title: 'b', value_summary: 'done' },
241
+ { id: 13, goal_id: 1, version_id: 'V1', status: 'ready', title: '__smoke__ fixture', value_summary: null },
242
+ { id: 21, goal_id: 2, version_id: 'V1', status: 'backlog', title: 'c', value_summary: null },
243
+ { id: 22, goal_id: 2, version_id: 'V1', status: 'active', title: 'd', value_summary: null },
244
+ { id: 51, goal_id: 5, version_id: 'V1', status: 'ready', title: 'e', value_summary: null },
245
+ ],
246
+ claims: [],
247
+ nextGoalId: 100,
248
+ };
249
+
250
+ const CLOSE_CASES = {
251
+ 'abandon and roll forward, with skips': {
252
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 3, verb: 'abandon' }, { goalId: 4, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }],
253
+ planningVersionId: 'V2',
254
+ },
255
+ 'plan order is not id order': {
256
+ plan: [{ goalId: 5, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }],
257
+ planningVersionId: 'V2',
258
+ },
259
+ 'a goal named twice reads archived the second time': {
260
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }],
261
+ planningVersionId: 'V2',
262
+ },
263
+ 'ids as strings': { plan: [{ goalId: '2', verb: 'abandon' }, { goalId: '5', verb: 'roll_forward' }], planningVersionId: 'V2' },
264
+ 'a roll forward with no planning version throws after earlier steps': {
265
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 2, verb: 'roll_forward' }],
266
+ planningVersionId: null,
267
+ },
268
+ 'an empty plan': { plan: [], planningVersionId: 'V2' },
269
+ };
270
+
271
+ for (const [name, { plan, planningVersionId }] of Object.entries(CLOSE_CASES)) {
272
+ test(`version close, old loop vs batched: ${name}`, async () => {
273
+ const args = { versionId: 'V1', plan, reason: 'V1 is done', planningVersionId };
274
+ const oldDb = fakeDb(CLOSE_SEED);
275
+ const old = await runLegacyTx(oldDb, (c) => legacy.legacyCloseLoop(c, args));
276
+ const neuDb = fakeDb(CLOSE_SEED);
277
+ const neu = await runNew(() => closeVersion(args, { pool: neuDb.pool }));
278
+
279
+ assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal');
280
+ if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
281
+ assert.deepEqual(neuDb.state.goals, oldDb.state.goals, 'the same goals, successors included');
282
+ assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks');
283
+ });
284
+ }
285
+
286
+ test('version close locks every plan goal in ONE statement, in id order', async () => {
287
+ const db = fakeDb(CLOSE_SEED);
288
+ await closeVersion({ versionId: 'V1', plan: CLOSE_CASES['plan order is not id order'].plan, reason: 'r', planningVersionId: 'V2' }, { pool: db.pool });
289
+ const locks = db.queries.filter((q) => /FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE/.test(q));
290
+ assert.equal(locks.length, 1);
291
+ assert.equal(db.queries.filter((q) => /FROM goals WHERE id = \$1 AND version_id/.test(q)).length, 0, 'no per-step lock is left');
292
+ });
293
+
294
+ // ---- weekly karma tally ------------------------------------------------------
295
+
296
+ const TALLY_SEED = {
297
+ goals: [], tasks: [], claims: [], nextGoalId: 1, karma: [],
298
+ peerVotes: [
299
+ { id: 1, builder_id: 30, vote: 1 }, { id: 2, builder_id: 10, vote: 1 }, { id: 3, builder_id: 30, vote: 1 },
300
+ { id: 4, builder_id: 20, vote: 1 }, { id: 5, builder_id: 20, vote: -1 }, // nets to zero: no row
301
+ { id: 6, builder_id: 40, vote: -1 },
302
+ ],
303
+ ideaVotes: [{ id: 7, builder_id: 10, vote: 1 }, { id: 8, builder_id: 50, vote: 1 }],
304
+ };
305
+
306
+ async function withFakePool(db, fn) {
307
+ const had = Object.prototype.hasOwnProperty.call(pgPool, 'connect');
308
+ const real = pgPool.connect;
309
+ pgPool.connect = db.pool.connect;
310
+ try { return await fn(); } finally { if (had) pgPool.connect = real; else delete pgPool.connect; }
311
+ }
312
+ const fakeWithTx = (db) => async (fn) => {
313
+ await db.client.query('BEGIN');
314
+ try { const r = await fn(db.client); await db.client.query('COMMIT'); return r; } catch (e) { await db.client.query('ROLLBACK'); throw e; }
315
+ };
316
+
317
+ for (const [name, seed] of Object.entries({
318
+ 'task and idea votes, a zero net, first-seen order': TALLY_SEED,
319
+ 'no ideas module': { ...TALLY_SEED, ideaVotes: null },
320
+ 'nothing matured': { ...TALLY_SEED, peerVotes: [], ideaVotes: [] },
321
+ })) {
322
+ test(`weekly karma tally, old loop vs batched: ${name}`, async () => {
323
+ const oldDb = fakeDb(seed);
324
+ const old = await legacy.legacyTallyPeerVotes(fakeWithTx(oldDb));
325
+ const neuDb = fakeDb(seed);
326
+ const neu = await withFakePool(neuDb, () => tallyPeerVotes());
327
+
328
+ assert.deepEqual(neu, old, 'the same reply');
329
+ assert.deepEqual(neuDb.state.karma, oldDb.state.karma, 'the same karma_log rows, in the same order');
330
+ assert.deepEqual(neuDb.state.peerVotes, oldDb.state.peerVotes, 'the same votes stamped');
331
+ assert.deepEqual(neuDb.state.ideaVotes, oldDb.state.ideaVotes);
332
+ });
333
+ }
334
+
335
+ test('the tally writes every credited builder in ONE insert', async () => {
336
+ const db = fakeDb(TALLY_SEED);
337
+ const r = await withFakePool(db, () => tallyPeerVotes());
338
+ assert.equal(r.builders_credited, 4);
339
+ assert.equal(db.queries.filter((q) => /^INSERT INTO karma_log/.test(q)).length, 1);
340
+ });
@@ -0,0 +1,198 @@
1
+ // tests/blockers_people.mjs — open blockers UNDER THE PEOPLE they wait on (task 1004999).
2
+ //
3
+ // WHAT IT GUARDS.
4
+ // * Every route the City carries now names the PEOPLE in its seat, not only the seat.
5
+ // * groupByPerson lists each open blocker under every person its seat resolves to,
6
+ // with the City's own rule (seatOf → resolveSeat): owner, a seated Lead, an
7
+ // unseated building falling to the owner, a board's members, and nobody.
8
+ // * The grouping and the City agree on every blocker — they are one rule.
9
+ // * Seating a different Lead moves the blocker with nothing on the blocker edited.
10
+ // * blockersByPerson reads the real contributed source, its asks and the seats.
11
+ // * The hall draws it: the viewer first as "You", the seat in words, "Nobody yet",
12
+ // escaping, and the City card's "waits on Lars, the owner".
13
+ //
14
+ // DB-free: the government db reads, the resolver and the asks read are stubbed.
15
+ // Run: node --test tests/blockers_people.mjs
16
+
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const gov = (f) => path.join(ROOT, 'modules', 'government', f);
27
+ const routing = require(gov('routing.js'));
28
+ const waiting = require(gov('waiting.js'));
29
+ const leads = require(gov('leads.js'));
30
+ const people = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'blockers-by-person.js'));
31
+
32
+ const NOW = Date.parse('2026-10-29T12:00:00Z');
33
+ const ARCHONS = [{ id: 1, github_login: 'lars', display_name: 'Lars' }];
34
+ const owner = leads.ownerOf(ARCHONS);
35
+ const planningLead = { id: 9, building: 'planning', builder_id: 42, github_login: 'ana', display_name: 'Ana', removed_at: null };
36
+ const releaseLead = { id: 10, building: 'release', builder_id: 43, github_login: 'ben', display_name: 'Ben', removed_at: null };
37
+ const ask = (waitsOn) => ({ question: 'Allow it?', waits_on: waitsOn, if_nobody_answers: 'It stays stuck.' });
38
+ const blocker = (id, over = {}) => ({
39
+ id: `blockers:${id}`, source: 'blockers', building: 'planning', state: 'needs', title: `Blocker ${id}`,
40
+ opened_at: `2026-10-2${id % 10}T00:00:00Z`, deadline: null, href: `/blockers#blocker-${id}`, ref: { type: 'blocker', id: String(id) }, ...over,
41
+ });
42
+
43
+ test('a route names the people in its seat, not only the seat', () => {
44
+ const seats = { leads: [planningLead], owner, holders: { charter: [{ builder_id: 7, github_login: 'cy', display_name: 'Cy' }, { builder_id: 8, github_login: 'di' }] } };
45
+ const byOwner = routing.routeMatter(blocker(1, { ask: ask('owner') }), '1', seats, NOW);
46
+ assert.deepEqual(byOwner.people, [{ id: '1', login: 'lars', name: 'Lars' }]);
47
+ const byLead = routing.routeMatter(blocker(2), '1', seats, NOW);
48
+ assert.equal(byLead.seat, 'lead:planning');
49
+ assert.deepEqual(byLead.people, [{ id: '42', login: 'ana', name: 'Ana' }]);
50
+ const unseated = routing.routeMatter(blocker(3, { building: 'treasury' }), '1', seats, NOW);
51
+ assert.deepEqual([unseated.seat, unseated.fell_to_owner, unseated.people.map((p) => p.name)], ['owner', true, ['Lars']]);
52
+ const board = routing.routeMatter(blocker(4, { ask: ask('board:charter') }), '1', seats, NOW);
53
+ assert.deepEqual(board.people, [{ id: '7', login: 'cy', name: 'Cy' }, { id: '8', login: 'di', name: 'di' }]);
54
+ });
55
+
56
+ test('the name map is built once per request: routeMatter takes it, and routeAll gives the same routes', () => {
57
+ const seats = { leads: [planningLead], owner, holders: {} };
58
+ // a passed map is the one used — so routeAll can hand every matter the one it built
59
+ const dir = new Map([['42', { id: '42', login: 'x', name: 'From the map' }]]);
60
+ assert.deepEqual(routing.routeMatter(blocker(1), '1', seats, NOW, dir).people, [{ id: '42', login: 'x', name: 'From the map' }]);
61
+ const ms = [blocker(1), blocker(2, { ask: ask('owner') }), blocker(3, { building: 'treasury' })];
62
+ const one = ms.map((m) => routing.routeMatter(m, '42', seats, NOW));
63
+ const all = ms.map((m) => ({ ...m }));
64
+ routing.routeAll(all, '42', seats, NOW);
65
+ assert.deepEqual(all.map((m) => m.route), one);
66
+ const src = fs.readFileSync(gov('routing.js'), 'utf8');
67
+ const body = src.slice(src.indexOf('function routeAll'), src.indexOf('\n}', src.indexOf('function routeAll')));
68
+ assert.match(body, /const dir = directoryOf\(ctx\);[\s\S]*routeMatter\(m, viewerId, ctx, now, dir\)/, 'built once, outside the loop');
69
+ });
70
+
71
+ test('groupByPerson: each blocker under every person its seat resolves to; nobody is never dropped', () => {
72
+ const seats = { leads: [planningLead], owner, holders: { charter: [{ builder_id: 7, display_name: 'Cy' }, { builder_id: 42, display_name: 'Ana' }], rules: [] } };
73
+ const matters = [
74
+ blocker(1, { ask: ask('owner') }),
75
+ blocker(2), // ask-less → lead:planning → Ana
76
+ blocker(3, { building: 'treasury' }), // unseated → the owner
77
+ blocker(4, { ask: ask('board:charter') }), // two members
78
+ blocker(5, { ask: ask('board:rules') }), // nobody seated → falls to the owner
79
+ blocker(6, { state: 'settled', ask: ask('owner') }), // settled is not waiting
80
+ ];
81
+ const g = waiting.groupByPerson(matters, seats);
82
+ const ids = (name) => g.people.find((x) => x.person.name === name).items.map((i) => i.id);
83
+ assert.deepEqual(ids('Lars'), ['1', '3', '5']);
84
+ assert.deepEqual(ids('Ana'), ['2', '4']);
85
+ assert.deepEqual(ids('Cy'), ['4']);
86
+ assert.deepEqual(g.people.map((p) => [p.person.name, p.count]), [['Lars', 3], ['Ana', 2], ['Cy', 1]], 'most waiting first');
87
+ const two = g.people.find((x) => x.person.name === 'Ana').items.find((i) => i.id === '2');
88
+ assert.deepEqual([two.seat, two.as, two.asked, two.fell_to_owner], ['lead:planning', 'the Planning Lead', 'lead:planning', false]);
89
+ const three = g.people.find((x) => x.person.name === 'Lars').items.find((i) => i.id === '3');
90
+ assert.deepEqual([three.seat, three.asked, three.fell_to_owner], ['owner', 'lead:treasury', true]);
91
+ // no owner at all: an owner-seat blocker waits on nobody, and is listed there
92
+ const empty = waiting.groupByPerson([blocker(1, { ask: ask('owner') })], { leads: [], owner: leads.ownerOf([]), holders: {} });
93
+ assert.deepEqual([empty.people.length, empty.nobody.map((i) => i.id)], [0, ['1']]);
94
+ });
95
+
96
+ test('the grouping and the City agree on every blocker', () => {
97
+ const seats = { leads: [planningLead, releaseLead], owner, holders: { charter: [{ builder_id: 7, display_name: 'Cy' }] } };
98
+ const matters = [blocker(1), blocker(2, { ask: ask('owner') }), blocker(3, { ask: ask('lead:release') }), blocker(4, { ask: ask('board:charter') }), blocker(5, { building: 'people' }), blocker(6, { ask: ask('committee:release.ops') })];
99
+ const g = waiting.groupByPerson(matters, seats);
100
+ for (const m of matters) {
101
+ const city = routing.routeMatter(m, null, seats, NOW).people.map((p) => p.id).sort();
102
+ const here = g.people.filter((x) => x.items.some((i) => i.matter_id === m.id)).map((x) => x.person.id).sort();
103
+ assert.deepEqual(here, city, `${m.id}: the same people on both`);
104
+ }
105
+ });
106
+
107
+ test('seating a different Lead moves the blocker; nothing on the blocker changes', () => {
108
+ const m = [blocker(1)];
109
+ const before = waiting.groupByPerson(m, { leads: [planningLead], owner, holders: {} });
110
+ const after = waiting.groupByPerson(m, { leads: [{ ...planningLead, builder_id: 77, github_login: 'eve', display_name: 'Eve' }], owner, holders: {} });
111
+ assert.deepEqual(before.people.map((p) => p.person.name), ['Ana']);
112
+ assert.deepEqual(after.people.map((p) => p.person.name), ['Eve']);
113
+ const removed = waiting.groupByPerson(m, { leads: [{ ...planningLead, removed_at: '2026-10-28T00:00:00Z' }], owner, holders: {} });
114
+ assert.deepEqual(removed.people.map((p) => p.person.name), ['Lars'], 'a removed Lead hands it back to the owner');
115
+ });
116
+
117
+ test('forViewer: the viewer\'s own group, or an empty one', () => {
118
+ const g = waiting.groupByPerson([blocker(1), blocker(2, { ask: ask('owner') })], { leads: [planningLead], owner, holders: {} });
119
+ assert.deepEqual(waiting.forViewer(g, 42).items.map((i) => i.id), ['1']);
120
+ assert.deepEqual(waiting.forViewer(g, '1').items.map((i) => i.id), ['2']);
121
+ assert.deepEqual([waiting.forViewer(g, 5).count, waiting.forViewer(g, 5).items], [0, []]);
122
+ });
123
+
124
+ test('blockersByPerson reads the contributed blockers, their asks and the seats', async () => {
125
+ const docket = require(gov('docket.js'));
126
+ const resolver = require(gov('resolver.js'));
127
+ const asksDb = require(gov('asks-db.js'));
128
+ const db = require(gov('db.js'));
129
+ const real = { perms: resolver.resolveBuilderPermissions, asks: asksDb.asksFor, leads: db.listLeads, archons: db.listActiveArchons };
130
+ resolver.resolveBuilderPermissions = async () => ['board.vote.cast'];
131
+ asksDb.asksFor = async (ids) => new Map(ids.filter((id) => id === 'blockers:7').map((id) => [id, ask('owner')]));
132
+ db.listLeads = async () => [planningLead];
133
+ db.listActiveArchons = async () => ARCHONS;
134
+ const rows = [{ id: 7, title: 'Token needs the workflow scope', created_at: '2026-10-29T10:00:00Z' }, { id: 8, title: 'Pick a host', created_at: '2026-10-28T10:00:00Z' }];
135
+ const contributions = { sources: new Map([['blockers', { source: 'blockers', building: 'planning', gate: { anyBuilder: true }, read: async () => rows.map((b) => ({ id: b.id, title: b.title, state: 'needs', opened_at: b.created_at, href: `/blockers#blocker-${b.id}`, ref: { type: 'blocker', id: b.id } })) }]]) };
136
+ try {
137
+ const r = await docket.blockersByPerson({ id: 42, rank: 'metic' }, { contributions, now: NOW });
138
+ assert.equal(r.available, true);
139
+ assert.deepEqual(r.people.map((p) => [p.person.name, p.items.map((i) => i.id)]), [['Ana', ['8']], ['Lars', ['7']]]);
140
+ assert.deepEqual(r.yours.items.map((i) => i.id), ['8'], 'Ana sees her own');
141
+ const off = await docket.blockersByPerson({ id: 42 }, { contributions: { sources: new Map() }, now: NOW });
142
+ assert.deepEqual([off.available, off.reason, off.people], [false, 'off', []], 'no source is said, not shown as empty');
143
+ db.listLeads = async () => { throw new Error('db down'); };
144
+ const down = await docket.blockersByPerson({ id: 42 }, { contributions, now: NOW });
145
+ assert.deepEqual([down.available, down.reason], [false, 'unavailable'], 'a failed seats read groups nothing rather than guessing');
146
+ } finally {
147
+ resolver.resolveBuilderPermissions = real.perms; asksDb.asksFor = real.asks; db.listLeads = real.leads; db.listActiveArchons = real.archons;
148
+ }
149
+ });
150
+
151
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
152
+ const fmtDate = (iso) => iso.slice(0, 10);
153
+
154
+ test('the Blockers page: the viewer first as "You", the seat in words, "Nobody yet", escaped', () => {
155
+ const g = waiting.groupByPerson([
156
+ blocker(1, { ask: ask('owner'), title: '<b>Token</b>' }),
157
+ blocker(2), blocker(3, { building: 'treasury' }),
158
+ blocker(4, { ask: ask('board:rules') }),
159
+ ], { leads: [planningLead], owner: leads.ownerOf([]), holders: { rules: [] } });
160
+ const html = people.peopleHtml({ available: true, ...g }, { esc, fmtDate, viewerId: 42, blockerHref: (id) => `#/blocker/${id}` });
161
+ assert.ok(html.indexOf('>You<') < html.indexOf('>Nobody yet<'), 'the viewer first');
162
+ assert.match(html, /as the Planning Lead · filed 2026-10-22/);
163
+ assert.match(html, /href="#\/blocker\/2"/);
164
+ assert.match(html, /&lt;b&gt;Token&lt;\/b&gt;/, 'a title is escaped');
165
+ assert.ok(!html.includes('<b>Token'), 'never raw');
166
+ assert.equal(people.peopleHtml({ available: false, people: [], nobody: [] }, { esc, fmtDate }), '', 'unavailable draws nothing');
167
+ assert.equal(people.peopleHtml({ available: true, people: [], nobody: [] }, { esc, fmtDate }), '', 'nothing open draws nothing');
168
+ assert.equal(people.asWords({ as: 'the owner', asked: 'lead:treasury', fell_to_owner: true }), 'as the owner, since Treasury has no Lead');
169
+ assert.equal(people.asWords({ as: 'the owner', asked: 'owner', fell_to_owner: false }), 'as the owner');
170
+ });
171
+
172
+ test('Collab\'s block draws only the viewer\'s own, and nothing when nothing waits', () => {
173
+ const mine = { available: true, count: 1, items: [{ id: '9', title: 'Pick a host', as: 'the Planning Lead', asked: 'lead:planning', opened_at: '2026-10-28T00:00:00Z' }] };
174
+ const html = people.mineHtml(mine, { esc, fmtDate, blockerHref: (id) => `#/blocker/${id}` });
175
+ assert.match(html, /Pick a host/);
176
+ assert.match(html, /as the Planning Lead/);
177
+ assert.equal(people.mineHtml({ available: true, count: 0, items: [] }, { esc, fmtDate }), '');
178
+ assert.equal(people.mineHtml(null, { esc, fmtDate }), '');
179
+ });
180
+
181
+ test('the pages carry the sections and load the script', () => {
182
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
183
+ assert.match(pub('blockers.html'), /id="blockers-people-scroll"[^>]*hidden/);
184
+ assert.match(pub('blockers.html'), /<script src="\/builders\/blockers-by-person.js"><\/script>/);
185
+ assert.match(pub('collab.html'), /id="cb-blockers-scroll"[^>]*hidden/);
186
+ assert.match(pub('collab.html'), /<script src="\/builders\/blockers-by-person.js"><\/script>/);
187
+ });
188
+
189
+ test('the City card says who: "waits on Lars, the owner"', () => {
190
+ const deck = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'street-deck.js'));
191
+ const who = deck.whoWords;
192
+ assert.equal(who({ label: 'the owner', people: [{ name: 'Lars' }] }), 'Lars, the owner');
193
+ assert.equal(who({ label: 'the board', people: [{ name: 'Cy' }, { login: 'di' }] }), 'Cy and di, the board');
194
+ assert.equal(who({ label: 'the board', people: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] }), 'A and 2 others, the board');
195
+ assert.equal(who({ label: 'the owner', people: [] }), 'the owner', 'nobody named: the seat alone');
196
+ const foot = deck.footOf({ waits_on: { real: true, label: 'the Planning Lead', people: [{ name: 'Ana' }] } }, '');
197
+ assert.equal(foot.rest, 'It waits on Ana, the Planning Lead.');
198
+ });