@bongos/core 1.23.22 → 1.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.bongos-core.json +933 -413
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +2 -2
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +96 -0
  27. package/clients/bongos-client/index.cjs +96 -0
  28. package/clients/bongos-client/index.d.ts +155 -4
  29. package/clients/bongos-client/index.mjs +96 -0
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
  31. package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
  32. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
  33. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  34. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  35. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  36. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  37. package/docs/adr/README.md +6 -2
  38. package/docs/api/openapi.json +8651 -5833
  39. package/docs/api-reference.md +66 -10
  40. package/docs/architecture.md +37 -1
  41. package/docs/canonical-permissions.md +1 -1
  42. package/docs/copy-inventory.md +427 -339
  43. package/docs/copy-registry.json +1376 -500
  44. package/docs/file-map.md +1 -0
  45. package/docs/module-api-changelog.md +7 -0
  46. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  47. package/docs/onboarding/diagrams/README.md +1 -1
  48. package/docs/onboarding/drachmae-and-karma.md +15 -15
  49. package/docs/onboarding/primer.md +2 -2
  50. package/docs/page-inventory.json +57 -7
  51. package/docs/page-readings.json +1210 -1027
  52. package/docs/recipes/google-sign-in.md +60 -0
  53. package/docs/recipes/ops-gotchas.md +1 -1
  54. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  55. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  56. package/migrations/core_283_payout_approvals.sql +183 -0
  57. package/migrations/core_284_payout_gates.sql +179 -0
  58. package/migrations/core_285_task_needs.sql +74 -0
  59. package/modules/ai-studio/billing.js +67 -0
  60. package/modules/ai-studio/context.js +29 -0
  61. package/modules/ai-studio/db.js +172 -0
  62. package/modules/ai-studio/gemini.js +75 -0
  63. package/modules/ai-studio/generate.js +119 -0
  64. package/modules/ai-studio/keys.js +73 -0
  65. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  66. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  67. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  68. package/modules/ai-studio/openrouter.js +90 -0
  69. package/modules/ai-studio/payments.js +105 -0
  70. package/modules/ai-studio/pricing.js +92 -0
  71. package/modules/ai-studio/routes/ai-studio.js +235 -5
  72. package/modules/ai-studio/studio.js +121 -0
  73. package/modules/autonomy/fence.js +64 -1
  74. package/modules/builder-settings/builder-needs.js +27 -1
  75. package/modules/copy-desk/routes/copy-desk.js +5 -0
  76. package/modules/economy/achievements.js +22 -10
  77. package/modules/economy/cost-classify.js +9 -0
  78. package/modules/economy/cost-write-guard.js +52 -0
  79. package/modules/economy/cost.js +2 -1
  80. package/modules/economy/credits.js +18 -15
  81. package/modules/economy/goal-reward.js +161 -0
  82. package/modules/economy/reward.js +4 -0
  83. package/modules/economy/routes/cost.js +33 -7
  84. package/modules/government/access-routes.js +70 -0
  85. package/modules/government/access.js +88 -0
  86. package/modules/government/asks.js +5 -2
  87. package/modules/government/catalog.js +6 -0
  88. package/modules/government/db.js +72 -0
  89. package/modules/government/docket.js +48 -2
  90. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  91. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  92. package/modules/government/pollers/board-expiry.js +108 -20
  93. package/modules/government/protected-surfaces.json +1 -0
  94. package/modules/government/routes/government.js +31 -0
  95. package/modules/government/routing.js +49 -7
  96. package/modules/government/waiting.js +78 -0
  97. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  98. package/modules/hall-ui/public/ai-studio.css +193 -0
  99. package/modules/hall-ui/public/ai-studio.html +98 -0
  100. package/modules/hall-ui/public/ai-studio.js +556 -0
  101. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  102. package/modules/hall-ui/public/blockers-by-person.js +158 -0
  103. package/modules/hall-ui/public/blockers-lib.js +15 -1
  104. package/modules/hall-ui/public/blockers.html +8 -0
  105. package/modules/hall-ui/public/blockers.js +1 -1
  106. package/modules/hall-ui/public/claim-action.js +3 -0
  107. package/modules/hall-ui/public/collab.html +12 -0
  108. package/modules/hall-ui/public/craft-mode.js +4 -1
  109. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  110. package/modules/hall-ui/public/drachmae.html +1 -1
  111. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  112. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  113. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  114. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  115. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  120. package/modules/hall-ui/public/goal-inbox.js +1 -1
  121. package/modules/hall-ui/public/goals-render.js +4 -0
  122. package/modules/hall-ui/public/goals.html +8 -0
  123. package/modules/hall-ui/public/gov-living.js +6 -2
  124. package/modules/hall-ui/public/government-access.js +108 -0
  125. package/modules/hall-ui/public/government.html +21 -0
  126. package/modules/hall-ui/public/hall-ask.css +5 -0
  127. package/modules/hall-ui/public/hall-ask.js +39 -3
  128. package/modules/hall-ui/public/notes-panel.css +20 -0
  129. package/modules/hall-ui/public/notes-panel.js +146 -0
  130. package/modules/hall-ui/public/project-settings.html +1 -1
  131. package/modules/hall-ui/public/project-settings.js +7 -5
  132. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  133. package/modules/hall-ui/public/settings.html +15 -0
  134. package/modules/hall-ui/public/street-deck.js +12 -3
  135. package/modules/hall-ui/public/studio.css +90 -8
  136. package/modules/hall-ui/public/studio.html +39 -10
  137. package/modules/hall-ui/public/studio.js +148 -18
  138. package/modules/hall-ui/public/task.html +2 -0
  139. package/modules/hall-ui/public/task.js +4 -0
  140. package/modules/hall-ui/public/tweak-editor.js +12 -3
  141. package/modules/hall-ui/public/welcome-boot.css +42 -6
  142. package/modules/hall-ui/public/welcome-boot.js +20 -6
  143. package/modules/hall-ui/public/work.js +20 -0
  144. package/modules/hall-ui/records/ai-studio.md +36 -0
  145. package/modules/hall-ui/records/blockers-people.md +38 -0
  146. package/modules/lifecycle/db-claims.js +32 -4
  147. package/modules/lifecycle/db-goals.js +18 -5
  148. package/modules/lifecycle/db-tasks.js +15 -6
  149. package/modules/lifecycle/done-when.js +23 -1
  150. package/modules/lifecycle/lifecycle.js +5 -0
  151. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  152. package/modules/lifecycle/module.json +2 -1
  153. package/modules/lifecycle/notes.js +219 -0
  154. package/modules/lifecycle/page-notice.js +124 -0
  155. package/modules/lifecycle/page-tweak-artist.js +18 -1
  156. package/modules/lifecycle/publish-reconciler.js +67 -20
  157. package/modules/lifecycle/routes/claims.js +5 -2
  158. package/modules/lifecycle/routes/goals.js +3 -1
  159. package/modules/lifecycle/routes/notes.js +91 -0
  160. package/modules/lifecycle/routes/project-settings.js +43 -35
  161. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  162. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  163. package/modules/lifecycle/routes/tasks.js +12 -12
  164. package/modules/lifecycle/task-needs-gate.js +70 -0
  165. package/modules/market-watch/CLAUDE.md +15 -0
  166. package/modules/market-watch/db.js +340 -0
  167. package/modules/market-watch/key.js +58 -0
  168. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  169. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  170. package/modules/market-watch/module.json +26 -0
  171. package/modules/market-watch/pollers/hub-seed.js +54 -0
  172. package/modules/market-watch/pollers/weekly-run.js +100 -0
  173. package/modules/market-watch/research.js +350 -0
  174. package/modules/market-watch/run.js +245 -0
  175. package/modules/market-watch/seed/market-data.json +38 -0
  176. package/modules/market-watch/seed/partners-data.json +497 -0
  177. package/modules/market-watch/seed.js +252 -0
  178. package/modules/market-watch/store.js +230 -0
  179. package/modules/memory/routes/memory.js +14 -2
  180. package/modules/memory/routes/search.js +8 -2
  181. package/modules/platform-identity/account-logins.js +192 -0
  182. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  183. package/modules/platform-identity/platform-identity.js +5 -4
  184. package/modules/platform-identity/routes/my-projects.js +6 -0
  185. package/modules/platform-identity/routes/profile.js +18 -0
  186. package/modules/platform-identity/routes/public-profile.js +10 -1
  187. package/modules/platform-identity/routes/sso.js +26 -1
  188. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  189. package/modules/provisioning/credential-preflight.js +13 -3
  190. package/modules/provisioning/pollers/app-liveness.js +25 -5
  191. package/modules/provisioning/routes/provisioning.js +1 -1
  192. package/modules/provisioning/starter-bundles.js +6 -2
  193. package/modules/public-landing/public/account.html +10 -2
  194. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  195. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  196. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  197. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  198. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  199. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  200. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  201. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  202. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  203. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  205. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  209. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  210. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  213. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  214. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  221. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  222. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  223. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  224. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  225. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  226. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  227. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  228. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  229. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  230. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  233. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  244. package/modules/public-landing/public/community.html +1 -1
  245. package/modules/public-landing/public/connections.html +2 -2
  246. package/modules/public-landing/public/guild.html +1 -1
  247. package/modules/public-landing/public/invitations.html +2 -2
  248. package/modules/public-landing/public/projects.html +5 -5
  249. package/modules/raise/views/nda.html +2 -0
  250. package/modules/specialities/db.js +32 -1
  251. package/modules/specialities/routes/specialities.js +10 -6
  252. package/modules/status-ui/public/index.html +1 -1
  253. package/package-lock.json +2 -2
  254. package/package.json +1 -1
  255. package/release-notes.json +230 -0
  256. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  257. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  258. package/scripts/bongos/claim.js +22 -1
  259. package/scripts/bongos/db-backup-nightly.sh +29 -0
  260. package/scripts/bongos/deploy-divergence.js +7 -0
  261. package/scripts/bongos/doc-cli-guard.js +6 -0
  262. package/scripts/bongos/fitness.js +3 -2
  263. package/scripts/bongos/gen-diagrams.js +3 -3
  264. package/scripts/bongos/government-walk.js +3 -0
  265. package/scripts/bongos/lib.sh +4 -5
  266. package/scripts/bongos/migration-namespace.js +13 -1
  267. package/scripts/bongos/plain-cards.js +4 -0
  268. package/scripts/bongos/provision-github-setup.js +3 -1
  269. package/scripts/bongos/provision-units.js +6 -1
  270. package/scripts/bongos/publish-manifest.js +3 -0
  271. package/scripts/bongos/release.js +2 -0
  272. package/scripts/bongos/run-unit-tests.js +268 -82
  273. package/scripts/bongos/seed-tasks.example.json +56 -0
  274. package/scripts/bongos/seed-tasks.js +222 -0
  275. package/scripts/bongos/seed-verify-criteria.js +1 -2
  276. package/scripts/bongos/ship-finish.js +2 -0
  277. package/scripts/bongos/ship-resume.js +2 -0
  278. package/scripts/bongos/strand-lease.js +250 -0
  279. package/scripts/bongos/strand-watch.js +26 -5
  280. package/scripts/bongos/version-literal-guard.js +2 -2
  281. package/scripts/discord/post.js +4 -3
  282. package/scripts/gds/seed-tasks.js +7 -0
  283. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  284. package/scripts/hall-preview/server.js +21 -0
  285. package/src/bongos/auth-config.js +29 -1
  286. package/src/bongos/auth-discord.js +3 -3
  287. package/src/bongos/auth-github.js +3 -6
  288. package/src/bongos/auth-google.js +217 -0
  289. package/src/bongos/device-approval.js +140 -0
  290. package/src/bongos/llm-pricing.js +17 -1
  291. package/src/bongos/metrics.js +48 -2
  292. package/src/bongos/middleware/rate-limit.js +30 -11
  293. package/src/bongos/module-scope-map.js +12 -1
  294. package/src/bongos/optimizer.js +15 -1
  295. package/src/bongos/payout-approvals.js +342 -0
  296. package/src/bongos/payout-gates.js +364 -0
  297. package/src/bongos/prelaunch-gate.js +7 -9
  298. package/src/bongos/prelaunch-switch.js +20 -0
  299. package/src/bongos/route-rank-check.js +7 -0
  300. package/src/bongos/routes/auth-google.js +246 -0
  301. package/src/bongos/routes/auth.js +38 -1
  302. package/src/bongos/routes/backup.js +33 -9
  303. package/src/bongos/routes/builders.js +57 -30
  304. package/src/bongos/routes/github.js +9 -0
  305. package/src/bongos/routes/me.js +18 -0
  306. package/src/bongos/routes/my-payouts.js +133 -0
  307. package/src/bongos/routes/payout-approvals.js +312 -0
  308. package/src/bongos/routes/payout-gates.js +179 -0
  309. package/src/bongos/routes.js +15 -1
  310. package/src/bongos/secret-box.js +18 -3
  311. package/src/bongos/serve-internal.js +47 -19
  312. package/src/bongos/sign-in-pages.js +138 -0
  313. package/src/bongos/task-needs.js +146 -0
  314. package/src/bongos-downloads.js +18 -1
  315. package/src/instance-config.js +28 -0
  316. package/src/module-api.js +14 -1
  317. package/tests/access_holders.mjs +214 -0
  318. package/tests/account_logins.mjs +326 -0
  319. package/tests/account_logins_db.mjs +161 -0
  320. package/tests/ai_studio_billing.mjs +253 -0
  321. package/tests/ai_studio_generate.mjs +282 -0
  322. package/tests/ai_studio_keys.mjs +159 -0
  323. package/tests/ai_studio_requests.mjs +319 -0
  324. package/tests/auth_backchannel_logout_route.mjs +201 -0
  325. package/tests/auto_satisfy_criteria.mjs +10 -1
  326. package/tests/autobongos_tweak_lane.mjs +144 -0
  327. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  328. package/tests/backfill_subagent_rewards.mjs +17 -1
  329. package/tests/backup_trigger_lock.mjs +119 -0
  330. package/tests/blockers_people.mjs +198 -0
  331. package/tests/bounded_rate_maps.mjs +184 -0
  332. package/tests/claimable_blocker_status.mjs +1 -1
  333. package/tests/claims_batch_routes.mjs +421 -0
  334. package/tests/config_read_cache.mjs +171 -0
  335. package/tests/copy_desk_page_editor.mjs +12 -0
  336. package/tests/cost_write_guard.mjs +200 -0
  337. package/tests/craft_achievements.mjs +33 -10
  338. package/tests/criterion_goal_attach.mjs +1 -1
  339. package/tests/db_backup_nightly.mjs +73 -1
  340. package/tests/deploy_divergence_line.mjs +7 -0
  341. package/tests/effective_visibility_predicate.mjs +12 -0
  342. package/tests/fitness.mjs +19 -0
  343. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  344. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  345. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  346. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  347. package/tests/github_one_trip.mjs +34 -0
  348. package/tests/github_repo_visibility.mjs +20 -0
  349. package/tests/goal_achievement.mjs +30 -1
  350. package/tests/goal_achievement_reward.mjs +205 -0
  351. package/tests/goal_sort_staleness.mjs +2 -1
  352. package/tests/goal_tier.mjs +11 -1
  353. package/tests/google_fonts_nonblocking.mjs +84 -0
  354. package/tests/google_sign_in.mjs +401 -0
  355. package/tests/government_board_expiry.mjs +94 -0
  356. package/tests/government_board_membership.mjs +4 -1
  357. package/tests/government_protected_surfaces.mjs +1 -1
  358. package/tests/government_routes.mjs +1 -1
  359. package/tests/government_seed.mjs +1 -0
  360. package/tests/hall_ai_studio.mjs +347 -0
  361. package/tests/hall_page_gate_map.mjs +3 -0
  362. package/tests/hall_studio_home.mjs +61 -2
  363. package/tests/home_attention_cards.mjs +1 -1
  364. package/tests/human_signal.mjs +141 -0
  365. package/tests/install_script_reader.mjs +51 -0
  366. package/tests/integration_fixture_ids.mjs +82 -0
  367. package/tests/invite_expiry_db.mjs +13 -1
  368. package/tests/lib_sh_resolution.mjs +0 -4
  369. package/tests/market_watch_data.mjs +244 -0
  370. package/tests/market_watch_data_db.mjs +308 -0
  371. package/tests/market_watch_module.mjs +114 -0
  372. package/tests/market_watch_run.mjs +503 -0
  373. package/tests/metrics.mjs +124 -0
  374. package/tests/migration_namespace.mjs +14 -0
  375. package/tests/module_api.mjs +2 -0
  376. package/tests/module_loader.mjs +1 -1
  377. package/tests/notes.mjs +182 -0
  378. package/tests/own_scope_guard.mjs +5 -0
  379. package/tests/page_tweak_marker_sql.mjs +76 -0
  380. package/tests/page_tweak_no_block.mjs +135 -0
  381. package/tests/page_tweak_notice.mjs +103 -0
  382. package/tests/payout_approvals.mjs +253 -0
  383. package/tests/payout_approvals_db.mjs +106 -0
  384. package/tests/payout_gates.mjs +322 -0
  385. package/tests/payout_gates_db.mjs +113 -0
  386. package/tests/planning_session_skill.mjs +13 -3
  387. package/tests/project_settings_page.mjs +29 -5
  388. package/tests/project_settings_spend_cap_route.mjs +235 -0
  389. package/tests/promotion_sweep.mjs +29 -1
  390. package/tests/provision.mjs +3 -2
  391. package/tests/provisioning_app_status.mjs +18 -0
  392. package/tests/provisioning_credential_preflight.mjs +53 -0
  393. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  394. package/tests/publish_reconciler_overlap.mjs +60 -0
  395. package/tests/rank_change_broadcast_port.mjs +151 -0
  396. package/tests/seed_tasks.mjs +176 -0
  397. package/tests/seed_verify_criteria.mjs +2 -6
  398. package/tests/site_classified_once.mjs +142 -0
  399. package/tests/speciality_db_shape.mjs +23 -0
  400. package/tests/speciality_session_skills.mjs +48 -2
  401. package/tests/strand_lease.mjs +249 -0
  402. package/tests/strand_watch.mjs +10 -0
  403. package/tests/task_criteria_routes.mjs +275 -0
  404. package/tests/task_criteria_unlink_db.mjs +123 -0
  405. package/tests/task_needs.mjs +233 -0
  406. package/tests/task_needs_db.mjs +110 -0
  407. package/tests/unit_runner_serial.mjs +266 -43
  408. package/tests/version_literals.mjs +5 -7
  409. package/tests/welcome_boot.mjs +16 -5
  410. package/tests/welcome_boot_browser.mjs +36 -4
  411. package/docs/recipes/bongos-cli-release.md +0 -66
  412. package/scripts/bongos/backfill-disciplines.js +0 -144
  413. package/scripts/bongos/backfill-session-earnings.js +0 -123
  414. package/scripts/bongos/backfill-session-types.js +0 -87
  415. package/scripts/bongos/build-bongos.js +0 -94
  416. package/scripts/bongos/cleanup-orphans.js +0 -74
  417. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  418. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  419. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  420. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  421. package/scripts/bongos/smoke-batch-release.sh +0 -316
  422. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  423. package/scripts/bongos/smoke-dependencies.sh +0 -143
  424. package/scripts/gds/backfill-disciplines.js +0 -7
  425. package/scripts/gds/backfill-session-earnings.js +0 -7
  426. package/scripts/gds/backfill-session-types.js +0 -7
  427. package/scripts/gds/cleanup-orphans.js +0 -7
  428. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  429. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  430. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  431. package/scripts/gds/smoke-batch-claim.sh +0 -8
  432. package/scripts/gds/smoke-batch-release.sh +0 -8
  433. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  434. package/scripts/gds/smoke-dependencies.sh +0 -8
  435. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -0,0 +1,184 @@
1
+ // tests/bounded_rate_maps.mjs
2
+ //
3
+ // Task 1004964 (2026-10-28 server audit, item 10): every in-memory rate-limit and
4
+ // login map has a HARD size cap with oldest-first eviction, not only a time-based
5
+ // sweep that runs every five minutes. Each case floods a map past its cap through
6
+ // the real middleware (or the real tracker) and shows the size stays at the cap,
7
+ // the newest entries survive, and the oldest are the ones evicted.
8
+ // deviceFlows is also pruned on an unref'd timer, not only when a new flow starts.
9
+ //
10
+ // Hermetic: no DB, no network. Run: node tests/bounded_rate_maps.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import { makeRunner } from './helpers.mjs';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { test, summary } = makeRunner();
18
+
19
+ const fakeRes = () => ({ set() { return this; }, fail() { return this; } });
20
+ const noop = () => {};
21
+
22
+ // Drive `mw` with `n` distinct callers; `reqFor(i)` builds the request for caller i.
23
+ function flood(mw, n, reqFor) {
24
+ for (let i = 0; i < n; i++) mw(reqFor(i), fakeRes(), noop);
25
+ }
26
+ const byIp = (method = 'GET') => (i) => ({ method, ip: `ip-${i}`, headers: {}, path: '/' });
27
+ const byBuilder = (i) => ({ method: 'GET', builder: { id: i }, headers: {} });
28
+
29
+ // The one assertion every case makes: capped size, newest kept, oldest gone.
30
+ function assertCapped(map, cap, n, keyFor) {
31
+ assert.ok(n > cap, 'the flood must overrun the cap');
32
+ assert.equal(map.size, cap, `map holds ${map.size}, cap is ${cap}`);
33
+ assert.ok(map.has(keyFor(n - 1)), 'the newest caller survives');
34
+ assert.ok(map.has(keyFor(n - cap)), 'the oldest still-in-cap caller survives');
35
+ assert.ok(!map.has(keyFor(n - cap - 1)), 'the caller just past the cap was evicted');
36
+ assert.ok(!map.has(keyFor(0)), 'the first caller was evicted');
37
+ }
38
+
39
+ const rl = require('../src/bongos/middleware/rate-limit.js');
40
+ const CAP = rl.MAX_TRACKED_KEYS;
41
+ const N = CAP + 25;
42
+
43
+ await test('the shared cap is about 50k', () => {
44
+ assert.equal(CAP, 50000);
45
+ });
46
+
47
+ await test('rate-limit.js per-IP write bucket map stays capped', () => {
48
+ const m = rl._bucketMaps.ipBuckets;
49
+ m.clear();
50
+ flood(rl.rateLimitMiddleware, N, byIp('POST'));
51
+ assertCapped(m, CAP, N, (i) => `ip-${i}`);
52
+ m.clear();
53
+ });
54
+
55
+ await test('rate-limit.js per-session write bucket map stays capped', () => {
56
+ const m = rl._bucketMaps.sessionBuckets;
57
+ m.clear();
58
+ // A fresh IP per request: the per-IP check runs first and would 429 one IP early.
59
+ rl._bucketMaps.ipBuckets.clear();
60
+ flood(rl.rateLimitMiddleware, N, (i) => ({
61
+ method: 'POST', ip: `s-${i}`, headers: { authorization: `Bearer tok-${i}` },
62
+ }));
63
+ assertCapped(m, CAP, N, (i) => `b:tok-${i}`);
64
+ m.clear();
65
+ rl._bucketMaps.ipBuckets.clear();
66
+ });
67
+
68
+ await test('rate-limit.js public-read bucket map stays capped', () => {
69
+ const m = rl._bucketMaps.publicReadBuckets;
70
+ m.clear();
71
+ flood(rl.publicReadRateLimitMiddleware, N, byIp());
72
+ assertCapped(m, CAP, N, (i) => `ip-${i}`);
73
+ m.clear();
74
+ });
75
+
76
+ await test('rate-limit.js account-existence bucket map stays capped', () => {
77
+ const m = rl._bucketMaps.accountExistenceBuckets;
78
+ m.clear();
79
+ flood(rl.accountExistenceReadRateLimit, N, byIp());
80
+ assertCapped(m, CAP, N, (i) => `ip-${i}`);
81
+ m.clear();
82
+ });
83
+
84
+ await test('rate-limit.js public-project-feed bucket map stays capped', () => {
85
+ const m = rl._bucketMaps.projectFeedBuckets;
86
+ m.clear();
87
+ flood(rl.publicProjectFeedRateLimit, N, byIp());
88
+ assertCapped(m, CAP, N, (i) => `ip-${i}`);
89
+ m.clear();
90
+ });
91
+
92
+ await test('rate-limit.js default-read map keeps its tighter 10k cap', () => {
93
+ const m = rl._bucketMaps.defaultReadBuckets;
94
+ m.clear();
95
+ const mw = rl.defaultReadRateLimit({ stack: [] });
96
+ const cap = rl.DEFAULT_READ_MAX_TRACKED_IPS;
97
+ flood(mw, cap + 25, byIp());
98
+ assertCapped(m, cap, cap + 25, (i) => `ip-${i}`);
99
+ m.clear();
100
+ });
101
+
102
+ await test('memory sync + read bucket maps stay capped', () => {
103
+ const { _internals: mem } = require('../modules/memory/routes/memory.js');
104
+ assert.equal(mem.MAX_TRACKED_KEYS, CAP);
105
+ for (const [mw, map] of [[mem.syncRateLimit, mem.syncBuckets], [mem.readRateLimit, mem.readBuckets]]) {
106
+ map.clear();
107
+ flood(mw, N, byBuilder);
108
+ assertCapped(map, CAP, N, (i) => String(i));
109
+ map.clear();
110
+ }
111
+ });
112
+
113
+ await test('memory search bucket map stays capped', () => {
114
+ const { _internals: s } = require('../modules/memory/routes/search.js');
115
+ assert.equal(s.MAX_TRACKED_KEYS, CAP);
116
+ s.buckets.clear();
117
+ flood(s.searchRateLimit, N, byBuilder);
118
+ assertCapped(s.buckets, CAP, N, (i) => String(i));
119
+ s.buckets.clear();
120
+ });
121
+
122
+ await test('SSO applicant-read bucket map stays capped', () => {
123
+ const { _internals: sso } = require('../modules/platform-identity/routes/sso.js');
124
+ assert.equal(sso.APPLICANT_MAX_TRACKED_IPS, CAP);
125
+ sso.applicantReadBuckets.clear();
126
+ flood(sso.applicantReadRateLimit, N, byIp());
127
+ assertCapped(sso.applicantReadBuckets, CAP, N, (i) => `ip-${i}`);
128
+ sso.applicantReadBuckets.clear();
129
+ });
130
+
131
+ await test('public-profile bucket map stays capped', () => {
132
+ const { _internals: pp } = require('../modules/platform-identity/routes/public-profile.js');
133
+ assert.equal(pp.MAX_TRACKED_IPS, CAP);
134
+ pp.buckets.clear();
135
+ flood(pp.profileReadRateLimit, N, byIp());
136
+ assertCapped(pp.buckets, CAP, N, (i) => `ip-${i}`);
137
+ pp.buckets.clear();
138
+ });
139
+
140
+ await test('deviceFlows stays capped with oldest-first eviction', () => {
141
+ const ac = require('../src/bongos/auth-config.js');
142
+ assert.equal(ac.DEVICE_FLOW_MAX, CAP);
143
+ ac.deviceFlows.clear();
144
+ const live = () => ({ interval: 5, expires_at: Date.now() + 15 * 60_000, last_polled_at: 0 });
145
+ for (let i = 0; i < N; i++) ac.trackDeviceFlow(`dc-${i}`, live());
146
+ assertCapped(ac.deviceFlows, CAP, N, (i) => `dc-${i}`);
147
+ ac.deviceFlows.clear();
148
+ });
149
+
150
+ await test('deviceFlows is pruned on an unref\'d timer, not only on a new flow', () => {
151
+ // Load auth-config fresh with setInterval spied, to see the timer it arms.
152
+ const realSetInterval = globalThis.setInterval;
153
+ const armed = [];
154
+ globalThis.setInterval = (fn, ms, ...rest) => {
155
+ const t = realSetInterval(fn, ms, ...rest);
156
+ const unref = t.unref.bind(t);
157
+ const rec = { fn, ms, unrefd: false, t };
158
+ t.unref = () => { rec.unrefd = true; return unref(); };
159
+ armed.push(rec);
160
+ return t;
161
+ };
162
+ const key = require.resolve('../src/bongos/auth-config.js');
163
+ const cached = require.cache[key];
164
+ try {
165
+ delete require.cache[key];
166
+ const fresh = require('../src/bongos/auth-config.js');
167
+ const timer = armed.find((r) => r.fn === fresh.pruneDeviceFlows);
168
+ assert.ok(timer, 'a prune timer is armed at load');
169
+ assert.equal(timer.ms, fresh.DEVICE_FLOW_PRUNE_MS);
170
+ assert.ok(timer.ms >= 60_000, 'the prune timer is not a per-second wake');
171
+ assert.ok(timer.unrefd, 'the prune timer is unref\'d');
172
+ // And what it runs drops expired flows while keeping live ones, with no new flow started.
173
+ fresh.deviceFlows.set('old', { expires_at: Date.now() - 1 });
174
+ fresh.deviceFlows.set('new', { expires_at: Date.now() + 60_000 });
175
+ timer.fn();
176
+ assert.deepEqual([...fresh.deviceFlows.keys()], ['new']);
177
+ } finally {
178
+ globalThis.setInterval = realSetInterval;
179
+ for (const r of armed) clearInterval(r.t);
180
+ require.cache[key] = cached;
181
+ }
182
+ });
183
+
184
+ summary();
@@ -35,7 +35,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
35
35
  const repoRoot = join(__dirname, '..');
36
36
  const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
37
37
 
38
- const MIG = 'core_264_claimable_respects_dependencies.sql';
38
+ const MIG = 'core_285_task_needs.sql';
39
39
  const mig = read('migrations', MIG);
40
40
  // Comments carry the rationale; strip them when asserting on the executable body
41
41
  // so a claim in prose can never stand in for the real SQL.
@@ -0,0 +1,421 @@
1
+ // tests/claims_batch_routes.mjs — the batch claim and batch release routes,
2
+ // driven end to end through express and the error envelope (task 1004983;
3
+ // gaps 2 and 15 of credit-burn/test-gap-hunt.md).
4
+ //
5
+ // Why this file exists. releaseClaimsBatch (modules/lifecycle/db-claims.js) was
6
+ // named in no test, yet it is the one place a builder hands N claims back at
7
+ // once: it sets each task to 'ready' and clears needs_rebase, and its `force`
8
+ // flag skips the session check. The only thing that stops a builder releasing
9
+ // SOMEONE ELSE's claim is its per-item ownership compare. POST /claims/batch
10
+ // was never driven as a route either, so nothing pinned which gates sit in
11
+ // front of it.
12
+ //
13
+ // How. The doorway (src/module-api.js) is stubbed in require.cache BEFORE the
14
+ // route loads — db-claims.js destructures pool/withTx at require time, the
15
+ // tests/access_requests_invite.mjs pattern. requireBuilder admits whichever
16
+ // caller a test sets; withTx runs the REAL releaseClaimsBatch against a client
17
+ // that records every query and answers the claims read from a fixture. For
18
+ // POST /claims/batch the db facade's claimTasksBatch is swapped per test for a
19
+ // recording handler (the route reads it at call time), so each documented error
20
+ // can be thrown on demand.
21
+ //
22
+ // Every permission test asserts BOTH halves: the refusal, and that no write
23
+ // query ran.
24
+ //
25
+ // Run: node --test tests/claims_batch_routes.mjs
26
+
27
+ import assert from 'node:assert/strict';
28
+ import { test, before, after, beforeEach } from 'node:test';
29
+ import http from 'node:http';
30
+ import { createRequire } from 'node:module';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const express = require('express');
34
+
35
+ const apiPath = require.resolve('../src/module-api.js');
36
+ const realApi = require(apiPath);
37
+
38
+ // ── the recording pool ───────────────────────────────────────────────────────
39
+
40
+ let calls = []; // every query any path ran: { sql, params, via }
41
+ let claimFixture = []; // rows the claims read answers with
42
+ let activeClaims = []; // rows getActiveClaimsForBuilder answers with
43
+ let connects = 0; // pool.connect() count — claimTasksBatch's tx door
44
+
45
+ function answer(sql, params, via) {
46
+ calls.push({ sql, params, via });
47
+ // The release's locked read (FOR UPDATE) — distinct from the newcomer gate's
48
+ // active-claims read, which joins the same tables.
49
+ if (/FROM claims c JOIN tasks t ON t\.id = c\.task_id/.test(sql) && /FOR UPDATE/.test(sql)) {
50
+ const wanted = new Set(params[0].map(String));
51
+ return { rows: claimFixture.filter((r) => wanted.has(String(r.id))) };
52
+ }
53
+ if (/FROM claims c/.test(sql) && /released_at IS NULL/.test(sql)) {
54
+ return { rows: activeClaims };
55
+ }
56
+ return { rows: [], rowCount: 1 };
57
+ }
58
+ const client = {
59
+ async query(sql, params) { return answer(sql, params, 'tx'); },
60
+ release() {},
61
+ };
62
+ const pool = {
63
+ async query(sql, params) { return answer(sql, params, 'pool'); },
64
+ async connect() { connects += 1; return client; },
65
+ };
66
+ // Same shape as src/bongos/db-kernel.js withTx, on the recording client.
67
+ async function withTx(fn) {
68
+ await client.query('BEGIN');
69
+ try {
70
+ const out = await fn(client);
71
+ await client.query('COMMIT');
72
+ return out;
73
+ } catch (err) {
74
+ await client.query('ROLLBACK');
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ let caller = { id: '42', rank: 'thetes' };
80
+ let callerSession = 'sess-A';
81
+ function requireBuilder(req, _res, next) {
82
+ req.builder = { ...caller };
83
+ req.gdsSession = callerSession ? { session_id: callerSession } : undefined;
84
+ next();
85
+ }
86
+
87
+ require.cache[apiPath].exports = { ...realApi, pool, withTx, requireBuilder };
88
+
89
+ const db = require('../modules/lifecycle/db.js');
90
+ const realClaimTasksBatch = db.claimTasksBatch;
91
+ const buildClaimsRouter = require('../modules/lifecycle/routes/claims.js');
92
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
93
+
94
+ const router = buildClaimsRouter();
95
+ const app = express();
96
+ app.use(express.json());
97
+ app.use(attachFail);
98
+ app.use('/api/bongos', router);
99
+
100
+ let server, port;
101
+ before(async () => {
102
+ server = http.createServer(app);
103
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
104
+ port = server.address().port;
105
+ });
106
+ after(async () => { await new Promise((r) => server.close(r)); });
107
+
108
+ beforeEach(() => {
109
+ calls = []; claimFixture = []; activeClaims = []; connects = 0;
110
+ caller = { id: '42', rank: 'thetes' };
111
+ callerSession = 'sess-A';
112
+ db.claimTasksBatch = realClaimTasksBatch;
113
+ });
114
+
115
+ function post(pathname, body) {
116
+ return new Promise((resolve, reject) => {
117
+ const payload = JSON.stringify(body);
118
+ const req = http.request({
119
+ host: '127.0.0.1', port, path: pathname, method: 'POST',
120
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
121
+ }, (res) => {
122
+ let data = '';
123
+ res.on('data', (c) => { data += c; });
124
+ res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
125
+ });
126
+ req.on('error', reject);
127
+ req.write(payload);
128
+ req.end();
129
+ });
130
+ }
131
+
132
+ const RELEASE = '/api/bongos/claims/batch/release';
133
+ const CLAIM = '/api/bongos/claims/batch';
134
+
135
+ // Any statement that changes state. BEGIN/COMMIT and SELECTs are not writes.
136
+ const isWrite = (c) => /^\s*(UPDATE|INSERT|DELETE)\b/i.test(c.sql);
137
+ const writes = () => calls.filter(isWrite);
138
+ const claimReleases = () => writes().filter((c) => /UPDATE claims SET released_at/.test(c.sql));
139
+ const taskToReady = () => writes().filter((c) => /UPDATE tasks\s+SET status = 'ready'/.test(c.sql));
140
+
141
+ // A claim row as the locked read returns it. node-pg hands bigint columns back
142
+ // as strings, so the realistic default spells every id that way.
143
+ const row = (over) => ({
144
+ id: '101', task_id: '501', builder_id: '42', released_at: null,
145
+ claimed_at: '2026-10-29T10:00:00Z', worktree_name: 'tree-a',
146
+ creator_session_id: 'sess-A', title: 'Own task', ...over,
147
+ });
148
+
149
+ // ── POST /claims/batch/release — the mixed batch ─────────────────────────────
150
+
151
+ test('batch release [own, another builder\'s, own already-released, missing]: only the own open claim is released', async () => {
152
+ claimFixture = [
153
+ row({ id: '101', task_id: '501', title: 'Own task' }),
154
+ row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z', title: 'Their task' }),
155
+ row({ id: '103', task_id: '503', released_at: '2026-10-28T00:00:00Z', title: 'Old task' }),
156
+ ];
157
+ const r = await post(RELEASE, { claim_ids: [101, 102, 103, 999] });
158
+ assert.equal(r.status, 200);
159
+ assert.deepEqual(r.json.released, [{ claim_id: 101, task_id: '501', task_title: 'Own task' }]);
160
+ assert.deepEqual(r.json.failures, [
161
+ { claim_id: 102, reason: 'NOT_YOUR_CLAIM' },
162
+ { claim_id: 103, reason: 'ALREADY_RESOLVED' },
163
+ { claim_id: 999, reason: 'CLAIM_NOT_FOUND' },
164
+ ]);
165
+ // Exactly one claim closed and one task sent back to the queue — the own one.
166
+ assert.equal(claimReleases().length, 1);
167
+ assert.deepEqual(claimReleases()[0].params, [101]);
168
+ assert.equal(taskToReady().length, 1);
169
+ assert.deepEqual(taskToReady()[0].params, ['501']);
170
+ // Nothing at all was written about the other builder's claim or task.
171
+ for (const w of writes()) {
172
+ assert.ok(!w.params.map(String).some((p) => p === '102' || p === '502'),
173
+ `a write touched the other builder's claim or task: ${w.sql.trim().slice(0, 60)}`);
174
+ }
175
+ });
176
+
177
+ test('batch release of ONLY another builder\'s claim: refused, and no write query ran', async () => {
178
+ claimFixture = [row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
179
+ const r = await post(RELEASE, { claim_ids: [102] });
180
+ assert.equal(r.status, 200);
181
+ assert.deepEqual(r.json.released, []);
182
+ assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
183
+ assert.deepEqual(writes(), []);
184
+ });
185
+
186
+ test('force does not reach past ownership: another builder\'s claim with force:true is still refused, nothing written', async () => {
187
+ claimFixture = [row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
188
+ const r = await post(RELEASE, { claim_ids: [102], force: true });
189
+ assert.equal(r.status, 200);
190
+ assert.deepEqual(r.json.released, []);
191
+ assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
192
+ assert.deepEqual(writes(), []);
193
+ });
194
+
195
+ // ── the id-type matrix (node-pg bigint strings vs numbers) ────────────────────
196
+
197
+ test('another builder\'s claim is refused whatever the id types — string or number on either side', async () => {
198
+ for (const [rowId, callerId] of [['7', '42'], ['7', 42], [7, '42'], [7, 42]]) {
199
+ calls = [];
200
+ caller = { id: callerId, rank: 'thetes' };
201
+ claimFixture = [row({ id: '102', task_id: '502', builder_id: rowId, creator_session_id: 'sess-Z' })];
202
+ const r = await post(RELEASE, { claim_ids: [102] });
203
+ assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }],
204
+ `row builder_id ${JSON.stringify(rowId)} vs caller ${JSON.stringify(callerId)}`);
205
+ assert.deepEqual(writes(), []);
206
+ }
207
+ });
208
+
209
+ test('own claim with matching id types (both strings, as production sends them) is released', async () => {
210
+ caller = { id: '42', rank: 'thetes' };
211
+ claimFixture = [row({ builder_id: '42' }), row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
212
+ const r = await post(RELEASE, { claim_ids: [101, 102] });
213
+ assert.deepEqual(r.json.released.map((x) => x.claim_id), [101]);
214
+ assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
215
+ assert.equal(claimReleases().length, 1);
216
+ });
217
+
218
+ // KNOWN BUG (task 1005004, filed by task 1004983): releaseClaimsBatch compares
219
+ // claim.builder_id !== builderId strictly, so a STRING row id (node-pg bigint)
220
+ // against a NUMBER caller id refuses the caller's OWN claim as NOT_YOUR_CLAIM.
221
+ // Production is unaffected today only because req.builder.id is also a string.
222
+ // It fails closed (never releases a stranger's claim), so this pins CURRENT
223
+ // behaviour; when the compare is fixed, flip this to expect a release.
224
+ test('KNOWN BUG: own claim as string builder_id vs a NUMBER caller id is refused NOT_YOUR_CLAIM (fails closed)', async () => {
225
+ caller = { id: 42, rank: 'thetes' };
226
+ claimFixture = [row({ builder_id: '42' })];
227
+ const r = await post(RELEASE, { claim_ids: [101] });
228
+ assert.deepEqual(r.json.released, []);
229
+ assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'NOT_YOUR_CLAIM' }]);
230
+ assert.deepEqual(writes(), []);
231
+ });
232
+
233
+ // ── the session check, and force ─────────────────────────────────────────────
234
+
235
+ test('own claim bound to a different session, without force: CLAIM_SESSION_MISMATCH, nothing written', async () => {
236
+ callerSession = 'sess-A';
237
+ claimFixture = [row({ creator_session_id: 'sess-B', worktree_name: 'tree-b' })];
238
+ const r = await post(RELEASE, { claim_ids: [101] });
239
+ assert.equal(r.status, 200);
240
+ assert.deepEqual(r.json.released, []);
241
+ assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'CLAIM_SESSION_MISMATCH', claimWorktree: 'tree-b' }]);
242
+ assert.deepEqual(writes(), []);
243
+ });
244
+
245
+ test('own claim bound to a different session, WITH force: released', async () => {
246
+ callerSession = 'sess-A';
247
+ claimFixture = [row({ creator_session_id: 'sess-B', worktree_name: 'tree-b' })];
248
+ const r = await post(RELEASE, { claim_ids: [101], force: true });
249
+ assert.equal(r.status, 200);
250
+ assert.deepEqual(r.json.released.map((x) => x.claim_id), [101]);
251
+ assert.deepEqual(r.json.failures, []);
252
+ assert.equal(claimReleases().length, 1);
253
+ assert.equal(taskToReady().length, 1);
254
+ });
255
+
256
+ test('force must be the boolean true: a truthy string is refused by validation before any query', async () => {
257
+ claimFixture = [row({ creator_session_id: 'sess-B' })];
258
+ const r = await post(RELEASE, { claim_ids: [101], force: 'yes' });
259
+ assert.equal(r.status, 400);
260
+ assert.deepEqual(calls, []);
261
+ });
262
+
263
+ test('a released task goes back to ready with needs_rebase cleared, and the session log is sealed as abandoned', async () => {
264
+ claimFixture = [row()];
265
+ await post(RELEASE, { claim_ids: [101] });
266
+ const ready = taskToReady()[0];
267
+ assert.match(ready.sql, /needs_rebase = false/);
268
+ assert.match(ready.sql, /needs_rebase_reason = NULL/);
269
+ assert.match(claimReleases()[0].sql, /outcome = 'abandoned'/);
270
+ const seal = writes().find((c) => /INSERT INTO session_logs/.test(c.sql));
271
+ assert.ok(seal, 'session log sealed');
272
+ assert.equal(seal.params[0], '42', 'sealed under the caller, never a caller-supplied id');
273
+ assert.equal(seal.params[4], 'abandoned');
274
+ // All of it inside one transaction.
275
+ assert.equal(calls[0].sql, 'BEGIN');
276
+ assert.equal(calls[calls.length - 1].sql, 'COMMIT');
277
+ });
278
+
279
+ // ── the release body ──────────────────────────────────────────────────────────
280
+
281
+ test('release body: an empty array → 400 claim_ids_required, no query', async () => {
282
+ const r = await post(RELEASE, { claim_ids: [] });
283
+ assert.equal(r.status, 400);
284
+ assert.equal(r.json.error.code, 'claim_ids_required');
285
+ assert.deepEqual(calls, []);
286
+ });
287
+
288
+ test('release body: 21 ids → 400, no query', async () => {
289
+ const r = await post(RELEASE, { claim_ids: Array.from({ length: 21 }, (_, i) => i + 1) });
290
+ assert.equal(r.status, 400);
291
+ assert.deepEqual(calls, []);
292
+ });
293
+
294
+ test('release body: 20 ids is allowed through to the release', async () => {
295
+ const r = await post(RELEASE, { claim_ids: Array.from({ length: 20 }, (_, i) => i + 1) });
296
+ assert.equal(r.status, 200);
297
+ assert.equal(r.json.failures.length, 20);
298
+ });
299
+
300
+ test('release body: non-numeric ids → 400 claim_ids_must_be_numbers, no query', async () => {
301
+ const r = await post(RELEASE, { claim_ids: [101, 'abc'] });
302
+ assert.equal(r.status, 400);
303
+ assert.equal(r.json.error.code, 'claim_ids_must_be_numbers');
304
+ assert.deepEqual(calls, []);
305
+ });
306
+
307
+ test('release body: claim_ids missing → 400, no query', async () => {
308
+ const r = await post(RELEASE, {});
309
+ assert.equal(r.status, 400);
310
+ assert.deepEqual(calls, []);
311
+ });
312
+
313
+ // ── POST /claims/batch — the middleware chain ────────────────────────────────
314
+
315
+ function chainFor(path) {
316
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods.post);
317
+ assert.ok(layer, `POST ${path} is mounted`);
318
+ return layer.route.stack.map((s) => s.handle.name || '<handler>');
319
+ }
320
+
321
+ test('POST /claims/batch middleware chain is pinned: requireBuilder → gateNewcomerClaim → handler', () => {
322
+ assert.deepEqual(chainFor('/claims/batch'), ['requireBuilder', 'gateNewcomerClaim', '<handler>']);
323
+ });
324
+
325
+ test('POST /claims carries the one-claim-per-session gate that POST /claims/batch does not (the documented difference)', () => {
326
+ const single = chainFor('/claims');
327
+ assert.ok(single.includes('gateOneActiveClaimPerSession'));
328
+ assert.ok(single.includes('gateNewcomerClaim'));
329
+ assert.ok(!chainFor('/claims/batch').includes('gateOneActiveClaimPerSession'),
330
+ 'if batch claim gains the session gate, update this pin (task 1005005 asks whether it should)');
331
+ });
332
+
333
+ test('POST /claims/batch/release middleware chain is pinned: requireBuilder → handler', () => {
334
+ assert.deepEqual(chainFor('/claims/batch/release'), ['requireBuilder', '<handler>']);
335
+ });
336
+
337
+ // ── POST /claims/batch — the newcomer gate ───────────────────────────────────
338
+
339
+ test('a xenos with a claim in flight is refused by gateNewcomerClaim: 403, and no claim or write is attempted', async () => {
340
+ caller = { id: '42', rank: 'xenos' };
341
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
342
+ let batchCalls = 0;
343
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
344
+ const r = await post(CLAIM, { task_ids: [501, 502] });
345
+ assert.equal(r.status, 403);
346
+ assert.equal(r.json.error.code, 'newcomer_must_ship_first');
347
+ assert.equal(r.json.error.details.active_task_id, '700');
348
+ assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
349
+ assert.equal(connects, 0, 'no transaction was opened');
350
+ assert.deepEqual(writes(), []);
351
+ assert.equal(calls.length, 1, 'only the gate\'s own active-claims read');
352
+ });
353
+
354
+ test('a thetes with a claim in flight is NOT held by the newcomer gate — and makes no gate lookup', async () => {
355
+ caller = { id: '42', rank: 'thetes' };
356
+ activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
357
+ let batchCalls = 0;
358
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
359
+ const r = await post(CLAIM, { task_ids: [501] });
360
+ assert.equal(r.status, 201);
361
+ assert.equal(batchCalls, 1);
362
+ assert.deepEqual(calls, []);
363
+ });
364
+
365
+ // ── POST /claims/batch — each documented error maps to its status and body ────
366
+
367
+ test('success: 201 with the claims, ids coerced to numbers, caller and session from the server', async () => {
368
+ let seen;
369
+ db.claimTasksBatch = async (args) => { seen = args; return { claims: [{ id: 1, task_id: 501 }] }; };
370
+ const r = await post(CLAIM, { task_ids: ['501', 502], worktree_name: 'wt' });
371
+ assert.equal(r.status, 201);
372
+ assert.deepEqual(r.json.claims, [{ id: 1, task_id: 501 }]);
373
+ assert.deepEqual(seen, { taskIds: [501, 502], builderId: '42', worktreePrefix: 'wt', creatorSessionId: 'sess-A' });
374
+ });
375
+
376
+ test('bind_session:false claims session-unbound', async () => {
377
+ let seen;
378
+ db.claimTasksBatch = async (args) => { seen = args; return { claims: [] }; };
379
+ await post(CLAIM, { task_ids: [501], bind_session: false });
380
+ assert.equal(seen.creatorSessionId, null);
381
+ });
382
+
383
+ const ERROR_MAP = [
384
+ ['BATCH_FAILED', 409, 'batch_failed'],
385
+ ['BATCH_EMPTY', 400, 'task_ids_required'],
386
+ ['BATCH_DUPLICATE_IDS', 400, 'duplicate_task_ids'],
387
+ ['BUILDER_INACTIVE', 403, 'BUILDER_INACTIVE'],
388
+ ];
389
+ for (const [thrown, status, code] of ERROR_MAP) {
390
+ test(`claimTasksBatch throws ${thrown} → ${status} ${code}`, async () => {
391
+ const failures = [{ task_id: 501, reason: 'ALREADY_CLAIMED' }];
392
+ db.claimTasksBatch = async () => { throw { code: thrown, failures }; };
393
+ const r = await post(CLAIM, { task_ids: [501] });
394
+ assert.equal(r.status, status);
395
+ assert.equal(r.json.error.code, code);
396
+ if (thrown === 'BATCH_FAILED') assert.deepEqual(r.json.error.details, { failures });
397
+ });
398
+ }
399
+
400
+ test('an unexpected error → 500 batch_claim_failed with no internals in the message', async () => {
401
+ db.claimTasksBatch = async () => { throw new Error('connection refused at 10.0.0.1'); };
402
+ const r = await post(CLAIM, { task_ids: [501] });
403
+ assert.equal(r.status, 500);
404
+ assert.equal(r.json.error.code, 'batch_claim_failed');
405
+ assert.equal(r.json.error.message, 'internal error');
406
+ });
407
+
408
+ test('claim body: empty → 400 task_ids_required, 21 ids → 400, non-numeric → 400 task_ids_must_be_numbers; never reaches claimTasksBatch', async () => {
409
+ let batchCalls = 0;
410
+ db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
411
+ const empty = await post(CLAIM, { task_ids: [] });
412
+ assert.equal(empty.status, 400);
413
+ assert.equal(empty.json.error.code, 'task_ids_required');
414
+ const big = await post(CLAIM, { task_ids: Array.from({ length: 21 }, (_, i) => i + 1) });
415
+ assert.equal(big.status, 400);
416
+ const bad = await post(CLAIM, { task_ids: [1, 'x'] });
417
+ assert.equal(bad.status, 400);
418
+ assert.equal(bad.json.error.code, 'task_ids_must_be_numbers');
419
+ assert.equal(batchCalls, 0);
420
+ assert.deepEqual(calls, []);
421
+ });