@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,209 @@
1
+ // tests/shell_hooks.mjs — the per-shell-call hooks run in ONE node process
2
+ // (.claude/hooks/shell-hooks.js, task 1004931) and conclude exactly what the separate
3
+ // processes did. Every case SPAWNS the dispatcher with a real payload (the contract
4
+ // Claude Code sees: exit code, stdout JSON, stderr) and, where it matters, spawns the
5
+ // same hooks ALONE and compares — so a drift between a hook's run() and its own main()
6
+ // reds here.
7
+ //
8
+ // .claude/hooks/ is package-excluded, so a clean core checkout skips this file
9
+ // (tests/host_topology_skips.mjs carries the allowlist entry).
10
+ //
11
+ // Run: node tests/shell_hooks.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { requireHostModule } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const dispatcher = requireHostModule(require, '../.claude/hooks/shell-hooks.js');
25
+ const { plainCardLines } = require('../scripts/bongos/plain-cards.js');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ function t(name, fn) {
30
+ try { fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ const HOOKS = path.join(ROOT, '.claude', 'hooks');
35
+ const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-hooks-'));
36
+ const ENV = {
37
+ ...process.env, OTB_STUCK_DIR: DIR, CLAUDE_PROJECT_DIR: ROOT,
38
+ BONGOS_STUCK_DETECTOR_OFF: '', BONGOS_GIT_GUARD_OFF: '', BONGOS_SECRET_GATE_OFF: '', OTB_SECRET_GATE_OFF: '',
39
+ BONGOS_ALLOW_RULE_GUARD_OFF: '', OTB_SESSION_CARDS_OFF: '', OTB_SUBAGENT: '',
40
+ };
41
+ const PRE = ['secret-gate.js', 'git-guard.js', 'allow-rule-guard.js'];
42
+ const POST = ['lifecycle-card.js', 'stuck-detector.js'];
43
+
44
+ function spawn(file, args, input, env = {}) {
45
+ const r = spawnSync(process.execPath, [path.join(HOOKS, file), ...args], {
46
+ input: typeof input === 'string' ? input : JSON.stringify(input), encoding: 'utf8', timeout: 15000, env: { ...ENV, ...env },
47
+ });
48
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
49
+ }
50
+ const together = (names, payload, env) => spawn('shell-hooks.js', names, payload, env);
51
+ const alone = (name, payload, env) => spawn(name, [], payload, env);
52
+ const out = (r) => (r.stdout.trim() ? JSON.parse(r.stdout).hookSpecificOutput : null);
53
+ // A credential-shaped string built at runtime, so this file never holds one literally.
54
+ const FAKE_KEY = ['gh', 'p_'].join('') + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
55
+ const shell = (tool, command, extra = {}) => ({ session_id: `s-${Math.random()}`, tool_name: tool, tool_input: { command }, ...extra });
56
+
57
+ // ---- PreToolUse -----------------------------------------------------------
58
+ t('a clean command: exit 0, nothing on stdout or stderr — same as each hook alone', () => {
59
+ for (const tool of ['Bash', 'PowerShell']) {
60
+ const p = shell(tool, 'ls -la');
61
+ const r = together(PRE, p);
62
+ assert.deepEqual(r, { status: 0, stdout: '', stderr: '' }, tool);
63
+ for (const h of PRE) assert.equal(alone(h, p).status, 0, `${h} alone`);
64
+ }
65
+ });
66
+
67
+ t('git-guard blocks through the dispatcher with the exact stderr it prints alone', () => {
68
+ const p = shell('Bash', 'git stash push -m x');
69
+ const a = alone('git-guard.js', { ...p, session_id: 's-gg-alone' });
70
+ const r = together(PRE, { ...p, session_id: 's-gg-together' });
71
+ assert.equal(a.status, 2);
72
+ assert.equal(r.status, 2, 'a block is still exit 2');
73
+ assert.equal(r.stderr, a.stderr, 'the block reason is the one git-guard gives on its own');
74
+ assert.equal(r.stdout, '');
75
+ });
76
+
77
+ t('secret-gate blocks a credential in a shell command through the dispatcher', () => {
78
+ const p = shell('PowerShell', `echo ${FAKE_KEY}`);
79
+ const a = alone('secret-gate.js', p);
80
+ const r = together(PRE, p);
81
+ assert.equal(a.status, 2);
82
+ assert.equal(r.status, 2);
83
+ assert.equal(r.stderr, a.stderr);
84
+ });
85
+
86
+ t('two hooks blocking at once: exit 2 and BOTH reasons, never a JSON answer beside them', () => {
87
+ const p = shell('PowerShell', `git stash push -m ${FAKE_KEY}`);
88
+ const r = together(PRE, p);
89
+ assert.equal(r.status, 2);
90
+ assert.match(r.stderr, /secret-gate: blocked/);
91
+ assert.match(r.stderr, /git-guard: blocked/);
92
+ assert.equal(r.stdout, '', 'a block outranks the allow-rule-guard ask');
93
+ });
94
+
95
+ t('allow-rule-guard asks on PowerShell through the dispatcher, with its own JSON', () => {
96
+ const p = shell('PowerShell', 'git status $(Remove-Item x)');
97
+ const a = alone('allow-rule-guard.js', p);
98
+ const r = together(PRE, p);
99
+ assert.equal(r.status, 0);
100
+ assert.deepEqual(out(r), out(a));
101
+ assert.equal(out(r).permissionDecision, 'ask');
102
+ assert.equal(together(PRE, shell('Bash', 'git status $(rm x)')).stdout, '', 'Bash is not its tool');
103
+ });
104
+
105
+ t('kill switches still work through the dispatcher', () => {
106
+ assert.equal(together(PRE, shell('Bash', 'git stash push -m x'), { BONGOS_GIT_GUARD_OFF: '1' }).status, 0);
107
+ assert.equal(together(PRE, shell('Bash', `echo ${FAKE_KEY}`), { BONGOS_SECRET_GATE_OFF: '1' }).status, 0);
108
+ assert.equal(together(PRE, shell('PowerShell', 'git status $(x)'), { BONGOS_ALLOW_RULE_GUARD_OFF: '1' }).stdout, '');
109
+ });
110
+
111
+ t('the third git-guard block in a session still carries the stuck line (the shared counter runs)', () => {
112
+ const p = { ...shell('Bash', 'git branch -D x'), session_id: 's-third' };
113
+ together(PRE, p); together(PRE, p);
114
+ assert.match(together(PRE, p).stderr, /\[otb-stuck\]/);
115
+ });
116
+
117
+ // ---- PostToolUse ----------------------------------------------------------
118
+ t('stuck-detector context comes through the dispatcher on the third wall, as alone', () => {
119
+ const card = plainCardLines('TASK_NOT_READY', {}).join('\n');
120
+ const mk = (s) => ({ ...shell('Bash', 'node scripts/bongos/claim.js 9'), session_id: s, hook_event_name: 'PostToolUse', tool_response: { stdout: card, stderr: '' } });
121
+ for (let i = 0; i < 2; i++) { alone('stuck-detector.js', mk('s-post-alone')); together(POST, mk('s-post-together')); }
122
+ const a = alone('stuck-detector.js', mk('s-post-alone'));
123
+ const r = together(POST, mk('s-post-together'));
124
+ assert.equal(r.status, 0);
125
+ assert.deepEqual(out(r), out(a));
126
+ assert.match(out(r).additionalContext, /\[otb-stuck\]/);
127
+ });
128
+
129
+ t('lifecycle-card context comes through the dispatcher, as alone', () => {
130
+ const cardPath = path.join(DIR, 'otb-ship-card-77.html');
131
+ fs.writeFileSync(cardPath, '<div>card</div>');
132
+ const p = { ...shell('Bash', 'node scripts/bongos/claim.js 77'), hook_event_name: 'PostToolUse', tool_response: { stdout: `[otb-card-html] ${cardPath}\n`, stderr: '' } };
133
+ const a = alone('lifecycle-card.js', p);
134
+ const r = together(POST, p);
135
+ assert.ok(out(a), 'fixture: lifecycle-card alone injects a card');
136
+ assert.deepEqual(out(r), out(a));
137
+ assert.equal(together(POST, p, { OTB_SESSION_CARDS_OFF: '1' }).stdout, '', 'its kill switch holds');
138
+ });
139
+
140
+ t('two context producers in one call are merged into one JSON, both texts kept', () => {
141
+ const merged = dispatcher.merge([
142
+ { code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'one' } }) },
143
+ { code: 0, stdout: '' },
144
+ { code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'two' } }) },
145
+ ]);
146
+ assert.equal(merged.code, 0);
147
+ assert.deepEqual(JSON.parse(merged.stdout).hookSpecificOutput, { hookEventName: 'PostToolUse', additionalContext: 'one\n\ntwo' });
148
+ });
149
+
150
+ t('the most restrictive permission decision wins, with its own reason', () => {
151
+ const ans = (d, why) => ({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: d, permissionDecisionReason: why } }) });
152
+ const h = JSON.parse(dispatcher.merge([ans('ask', 'a'), ans('deny', 'd'), ans('allow', 'x')]).stdout).hookSpecificOutput;
153
+ assert.equal(h.permissionDecision, 'deny');
154
+ assert.equal(h.permissionDecisionReason, 'd');
155
+ });
156
+
157
+ // ---- failure modes --------------------------------------------------------
158
+ t('unparseable stdin: every hook fails open — exit 0, silent', () => {
159
+ assert.deepEqual(together([...PRE, ...POST], 'not json'), { status: 0, stdout: '', stderr: '' });
160
+ });
161
+
162
+ t('a missing hook is visible (exit 1, one line) and never blocks; the others still run', () => {
163
+ const r = together(['no-such-hook.js', 'git-guard.js'], shell('Bash', 'ls'));
164
+ assert.equal(r.status, 1);
165
+ assert.match(r.stderr, /no-such-hook\.js/);
166
+ const b = together(['no-such-hook.js', 'git-guard.js'], shell('Bash', 'git stash push -m x'));
167
+ assert.equal(b.status, 2, 'a real block still blocks beside a missing hook');
168
+ });
169
+
170
+ t('only plain sibling names load — no paths, not itself', () => {
171
+ for (const bad of ['../hooks/git-guard.js', 'sub/x.js', 'shell-hooks.js', 'git-guard', '']) {
172
+ assert.equal(dispatcher.loadHook(bad), null, bad);
173
+ }
174
+ assert.equal(typeof dispatcher.loadHook('git-guard.js'), 'function');
175
+ });
176
+
177
+ t('a hook that throws fails open; the rest of the roster still answers', () => {
178
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-hooks-throw-'));
179
+ fs.writeFileSync(path.join(tmp, 'boom.js'), 'module.exports = { run() { throw new Error("x"); } };');
180
+ fs.writeFileSync(path.join(tmp, 'ctx.js'), 'module.exports = { run: () => ({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "kept" } }) }) };');
181
+ const r = dispatcher.dispatch(['boom.js', 'ctx.js'], '{}', process.env, tmp);
182
+ assert.equal(r.code, 0);
183
+ assert.equal(JSON.parse(r.stdout).hookSpecificOutput.additionalContext, 'kept');
184
+ fs.rmSync(tmp, { recursive: true, force: true });
185
+ });
186
+
187
+ // ---- wiring ---------------------------------------------------------------
188
+ t('settings.json: one process per shell call on each side, rostered by file name, through run-node.sh', () => {
189
+ const settings = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
190
+ const shellEntries = (event) => (settings.hooks[event] || [])
191
+ .filter((g) => (g.matcher || '').split('|').includes('Bash'))
192
+ .flatMap((g) => g.hooks.map((h) => h.command));
193
+ const pre = shellEntries('PreToolUse');
194
+ const post = shellEntries('PostToolUse');
195
+ assert.equal(pre.length, 1, `PreToolUse spawns ${pre.length} processes per Bash call`);
196
+ assert.equal(post.length, 1, `PostToolUse spawns ${post.length} processes per Bash call`);
197
+ for (const [cmd, roster] of [[pre[0], PRE], [post[0], POST]]) {
198
+ assert.match(cmd, /run-node\.sh/);
199
+ assert.match(cmd, /shell-hooks\.js/);
200
+ for (const h of roster) assert.ok(cmd.includes(` ${h}`), `${h} missing from "${cmd}"`);
201
+ }
202
+ for (const h of [...PRE, ...POST]) {
203
+ assert.equal(typeof dispatcher.loadHook(h), 'function', `${h} exports run()`);
204
+ }
205
+ });
206
+
207
+ fs.rmSync(DIR, { recursive: true, force: true });
208
+ console.log(`\nshell_hooks: ${passed} passed, ${failed} failed`);
209
+ process.exitCode = failed ? 1 : 0;
@@ -385,5 +385,33 @@ await t('the gate sends the cause as the error MESSAGE, which is what the CLI pr
385
385
  assert.match(src, /res\.fail\(code, \{ status, message, details \}\)/);
386
386
  });
387
387
 
388
+ // Task 1004879: the audit row used method 'SHIP', which audit_log's CHECK
389
+ // refuses, so every write failed into a warning. Read the allowed list from the
390
+ // migration itself, so a future verb change is checked against the real rule.
391
+ await t('the ship-assurance audit row uses a method audit_log accepts', async () => {
392
+ const { recordShipAssurance } = require(path.join(ROOT, 'modules/lifecycle/ship-assurance.js'));
393
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations/033_audit_log.sql'), 'utf8');
394
+ const m = sql.match(/method\s+text NOT NULL CHECK \(method IN \(([^)]*)\)\)/);
395
+ assert.ok(m, 'migration 033 still declares the method CHECK');
396
+ const allowed = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
397
+ assert.ok(allowed.length >= 4, `parsed the allowed list: ${allowed}`);
398
+ const rows = [];
399
+ const warns = [];
400
+ await recordShipAssurance({
401
+ insertAuditLog: async (row) => {
402
+ if (!allowed.includes(row.method)) throw new Error('violates check constraint "audit_log_method_check"');
403
+ rows.push(row);
404
+ },
405
+ taskId: 7,
406
+ builderId: 1,
407
+ assurance: { kind: 'proof', sha: TIP },
408
+ log: { warn: (...a) => warns.push(a.join(' ')) },
409
+ });
410
+ assert.deepEqual(warns, []);
411
+ assert.equal(rows.length, 1);
412
+ assert.equal(rows[0].route, '/tasks/7/ship');
413
+ assert.deepEqual(rows[0].requestBodyRedacted, { ship_assurance: { kind: 'proof', sha: TIP } });
414
+ });
415
+
388
416
  console.log(`\n${passed} passed, ${failed} failed`);
389
417
  process.exit(failed ? 1 : 0);
@@ -54,6 +54,10 @@ test('fmtTokens / fmtUsd / fmtMins', () => {
54
54
  });
55
55
 
56
56
  // ---- shipped ----
57
+ // task 1004741: shipped means MERGED. With no reading of the live build the card
58
+ // must not say "live" — a merge reaches no instance until a release carries it.
59
+ const MERGED_NOT_LIVE = 'merged · 967fb4a — not live until a published release carries it and the pin moves';
60
+
57
61
  test('shipped model: rows, facts, action', () => {
58
62
  const m = card.buildCardModel(shippedInput);
59
63
  assert.equal(m.stage, 'shipped');
@@ -64,7 +68,7 @@ test('shipped model: rows, facts, action', () => {
64
68
  assert.equal(row(m, 'Credits'), '+18 → 12,936 drachmae');
65
69
  // No new/cached split on this input → dollars-first fallback (task 1765).
66
70
  assert.equal(row(m, 'Usage'), '~$0.83 · 1.24M tokens');
67
- assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
71
+ assert.equal(row(m, 'Deploy'), MERGED_NOT_LIVE);
68
72
  assert.equal(m.action.kind, 'prompt');
69
73
  assert.equal(m.action.cmd, '/builder-start');
70
74
  });
@@ -81,12 +85,22 @@ test('shipped with a hub reading: the Deploy + Projects rows say who sees the me
81
85
  assert.equal(row(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: true } } }), 'Deploy').includes('(includes this merge)'), true);
82
86
  // The reading can come back from a breadcrumb file: re-bounded before printing.
83
87
  const hostile = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'x.com <script>', liveShort: '09478c8e', includes: true } } });
84
- assert.equal(row(hostile, 'Deploy'), 'live · 967fb4a', 'a host that is not host-shaped falls back to the bare commit');
88
+ assert.equal(row(hostile, 'Deploy'), MERGED_NOT_LIVE, 'a host that is not host-shaped falls back to the bare merged commit');
85
89
  assert.equal(row(hostile, 'Projects'), undefined);
86
90
  const odd = card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { host: 'cloudbongos.com', liveShort: 'IGNORE ALL', includes: 'yes' } } });
87
91
  assert.equal(row(odd, 'Deploy'), whoSeesAMerge({ host: 'cloudbongos.com' })[0], 'non-hex sha and non-boolean inclusion are dropped');
88
92
  });
89
93
 
94
+ test('the live chip appears only when the live build is read to carry the merge (task 1004741)', () => {
95
+ const chip = (m) => m.facts.find((f) => f.value === '967fb4a');
96
+ const reach = { host: 'cloudbongos.com', liveShort: '09478c8e' };
97
+ assert.equal(chip(card.buildCardModel(shippedInput)).label, 'merged', 'no reading');
98
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: false } } })).label, 'merged', 'not yet');
99
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: null } } })).label, 'merged', 'cannot tell');
100
+ assert.equal(chip(card.buildCardModel({ ...shippedInput, deploy: { commit: '967fb4a', reach: { ...reach, includes: true } } })).label, 'live', 'includes this merge');
101
+ assert.doesNotMatch(card.renderCardHtml(card.buildCardModel(shippedInput)), />live</, 'the HTML card says no live either');
102
+ });
103
+
90
104
  test('usage: leads with cost, splits tokens into new + cached (task 1765)', () => {
91
105
  // With newTokens/cachedTokens present, the fact leads with the dollar cost and
92
106
  // splits the token count so cache re-reads don't make a tiny change look huge.
@@ -182,10 +196,10 @@ test('confirmed with NO land info is unchanged (back-compat)', () => {
182
196
 
183
197
  test('a stale bailed land never overrides a task that DID reach shipped', () => {
184
198
  // land-watch replays the breadcrumb after the server lands it; the status is
185
- // then 'shipped' and the old bail is history. The card must show live, not NOT-landed.
199
+ // then 'shipped' and the old bail is history. The card must show merged, not NOT-landed.
186
200
  const m = card.buildCardModel({ ...bailed, postGradeStatus: 'shipped' });
187
201
  assert.equal(m.stage, 'shipped');
188
- assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
202
+ assert.equal(row(m, 'Deploy'), MERGED_NOT_LIVE);
189
203
  assert.equal(m.cfg.tone, 'success');
190
204
  });
191
205
 
@@ -460,7 +474,8 @@ test('confirmLine: every stage yields one line naming the stage and the task', (
460
474
  test('confirmLine: a shipped line carries the facts the owner asks for ("did it land?")', () => {
461
475
  const line = card.confirmLine(card.buildCardModel(shippedInput));
462
476
  assert.match(line, /^shipped: task 1266/, 'leads with the outcome, not chrome');
463
- assert.match(line, /deploy live/, 'says it actually deployed — the commit rides in this row');
477
+ assert.match(line, /deploy merged · 967fb4a/, 'says it merged — the commit rides in this row');
478
+ assert.doesNotMatch(line, /\blive ·/, 'never claims live without a reading of the live build (task 1004741)');
464
479
  assert.match(line, /credits/, 'carries the credit outcome');
465
480
  });
466
481
 
@@ -0,0 +1,142 @@
1
+ // tests/site_classified_once.mjs — a request's site is worked out ONCE (task 1004963).
2
+ //
3
+ // THE COST THIS PINS. About fifteen global handlers in src/bongos/serve-internal.js
4
+ // each asked "is this the builders host?" / "is this a module-surface host?", and
5
+ // every answer re-parsed the Host header (the builders question also re-parsed the
6
+ // configured buildersOrigin URL). mountInternalSurfaces now classifies the request
7
+ // at the top of its chain into req.bongosSite and every predicate reads that field.
8
+ //
9
+ // HOW IT IS OBSERVED. A front middleware, mounted BEFORE mountInternalSurfaces,
10
+ // replaces the Host header value with a String object whose toLowerCase() counts
11
+ // its calls — every parse of the Host starts with that call. With the fix, the
12
+ // whole chain parses it exactly once (the classifier) whatever handler answers;
13
+ // without it, a hall request parses it once per host-routed handler it passes.
14
+ // (Counting plain property READS would be the wrong measure: siteOf re-reads the
15
+ // raw header on purpose, to check the cached answer is for the same Host.)
16
+ //
17
+ // WHAT ELSE IT PINS. The routing answer per host is unchanged — the hall, the
18
+ // canonical /builders redirects, the apex /docs, the API on both hosts — and the
19
+ // cache can never answer for a Host it was not computed from.
20
+ //
21
+ // node:http, not fetch: fetch silently drops a Host override (see
22
+ // tests/module_asset_subdomain.mjs for the trap).
23
+ //
24
+ // Run: node --test tests/site_classified_once.mjs
25
+
26
+ import { test } from 'node:test';
27
+ import assert from 'node:assert/strict';
28
+ import { createRequire } from 'node:module';
29
+ import http from 'node:http';
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ const APEX = 'example.com';
34
+ const HALL = 'builders.example.com';
35
+
36
+ // The count of Host parses for the request currently in flight. One request at a
37
+ // time (the helpers below await each), so a single counter is unambiguous.
38
+ let hostReads = 0;
39
+
40
+ // The same text as the real Host, but every toLowerCase() is counted. One instance
41
+ // per request, so siteOf's identity check against the cached raw value still holds.
42
+ class CountedHost extends String {
43
+ toLowerCase() { hostReads += 1; return String(this).toLowerCase(); }
44
+ }
45
+
46
+ async function withServer(fn) {
47
+ const express = require('express');
48
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
49
+ const app = express();
50
+ app.use((req, _res, next) => {
51
+ hostReads = 0;
52
+ req.headers.host = new CountedHost(req.headers.host);
53
+ next();
54
+ });
55
+ mountInternalSurfaces(app);
56
+ const server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
57
+ try { await fn(server.address().port); } finally { await new Promise((r) => server.close(r)); }
58
+ }
59
+
60
+ function get(port, path, host) {
61
+ return new Promise((resolve, reject) => {
62
+ const req = http.request({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => {
63
+ let body = '';
64
+ res.setEncoding('utf8');
65
+ res.on('data', (c) => { body += c; });
66
+ res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || null, body, reads: hostReads }));
67
+ });
68
+ req.on('error', reject);
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ // Requests that walk deep into the host-routed chain on each kind of host. The
74
+ // hall index on the builders host passes EVERY host-routed handler down to the
75
+ // final hall static; an unknown apex path falls through the whole chain to Express'
76
+ // default 404. Before the fix these read the Host header a dozen-plus times.
77
+ const WALKS = [
78
+ ['hall index on the builders host', '/', HALL],
79
+ ['hall asset on the builders host', '/builders/style.css', HALL],
80
+ ['unknown path on the builders host', '/no-such-page-xyz', HALL],
81
+ ['unknown path on the apex', '/no-such-page-xyz', APEX],
82
+ ['apex /docs', '/docs', APEX],
83
+ ['apex /builders redirect', '/builders/watch', APEX],
84
+ ['API healthz on the builders host', '/api/bongos/healthz', HALL],
85
+ ];
86
+
87
+ test('the Host header is parsed once per request, whichever handler answers', async () => {
88
+ await withServer(async (port) => {
89
+ for (const [label, path, host] of WALKS) {
90
+ const r = await get(port, path, host);
91
+ assert.equal(r.reads, 1, `${label}: Host read ${r.reads} times — the site must be classified once and read from req.bongosSite`);
92
+ }
93
+ });
94
+ });
95
+
96
+ test('routing per host is unchanged', async () => {
97
+ await withServer(async (port) => {
98
+ // Apex /builders/* → the builders subdomain (301, path + query carried).
99
+ let r = await get(port, '/builders/watch?x=1', APEX);
100
+ assert.equal(r.status, 301);
101
+ assert.equal(r.location, `https://${HALL}/watch?x=1`);
102
+ // On the builders host the /builders prefix is redundant → clean path.
103
+ r = await get(port, '/builders/watch', HALL);
104
+ assert.equal(r.status, 301);
105
+ assert.equal(r.location, '/watch');
106
+ // The builders host serves the hall at its root.
107
+ r = await get(port, '/', HALL);
108
+ assert.equal(r.status, 200);
109
+ assert.match(r.body, /<html/i);
110
+ // Retired directory → /roster, prefix kept on the apex and dropped on the hall.
111
+ r = await get(port, '/builders/people', HALL);
112
+ assert.equal(r.status, 301, 'the canonical redirect answers first on the hall host');
113
+ assert.equal(r.location, '/people');
114
+ // /docs: the apex serves the API reference.
115
+ r = await get(port, '/docs', APEX);
116
+ assert.equal(r.status, 200);
117
+ // The API is host-agnostic.
118
+ for (const host of [APEX, HALL]) {
119
+ r = await get(port, '/api/bongos/healthz', host);
120
+ assert.equal(r.status, 200, `API healthz on ${host}`);
121
+ }
122
+ });
123
+ });
124
+
125
+ test('siteOf caches on the request and never answers for a different Host', () => {
126
+ const { siteOf, isBuildersHost, isStatusHost } = require('../src/bongos/serve-internal.js');
127
+ const req = { headers: { host: 'Builders.Example.com:8443' } };
128
+ const first = siteOf(req);
129
+ assert.equal(first.host, 'builders.example.com');
130
+ assert.equal(first.builders, true);
131
+ assert.equal(req.bongosSite, first, 'the classification lives on the request');
132
+ assert.equal(siteOf(req), first, 'a second read reuses it');
133
+ // A changed Host is re-classified, not served from the stale entry.
134
+ req.headers.host = 'status.example.com';
135
+ assert.equal(isBuildersHost(req), false);
136
+ assert.equal(isStatusHost(req), true);
137
+ assert.notEqual(req.bongosSite, first);
138
+ // A missing Host is the apex, as before.
139
+ const bare = { headers: {} };
140
+ assert.equal(isBuildersHost(bare), false);
141
+ assert.equal(isStatusHost(bare), false);
142
+ });
@@ -112,6 +112,29 @@ test('countTraining counts both halves of training in one round trip', async ()
112
112
  assert.equal(q.calls.length, 1);
113
113
  });
114
114
 
115
+ // task 1004923: GET /me's specialities read is ONE query for all of them.
116
+ test('describeTrainingFor reads owner + both counts for every id in one round trip', async () => {
117
+ const q = fake([{ rows: [
118
+ { id: '7', owner_kind: 'builder', owner_builder_id: '1', documents: '3', learnings: '2' },
119
+ { id: '8', owner_kind: 'project', owner_builder_id: null, documents: '0', learnings: '5' },
120
+ ], rowCount: 2 }]);
121
+ const out = await db.describeTrainingFor(['7', 8, '7'], q);
122
+ assert.equal(q.calls.length, 1, 'one query, however many specialities');
123
+ assert.match(q.calls[0].sql, /c\.id = ANY\(\$1::bigint\[\]\)/);
124
+ assert.match(q.calls[0].sql, /specialities_documents d WHERE d\.speciality_id = c\.id/);
125
+ assert.match(q.calls[0].sql, /specialities_learnings l WHERE l\.speciality_id = c\.id/);
126
+ assert.deepEqual(q.calls[0].params, [['7', '8']], 'ids are de-duplicated before they are sent');
127
+ assert.deepEqual(out.get('7'), { owner_kind: 'builder', owner_builder_id: '1', documentCount: 3, learningCount: 2 });
128
+ assert.deepEqual(out.get('8'), { owner_kind: 'project', owner_builder_id: null, documentCount: 0, learningCount: 5 });
129
+ });
130
+
131
+ test('describeTrainingFor with no ids asks the database nothing', async () => {
132
+ const q = fake();
133
+ const out = await db.describeTrainingFor([], q);
134
+ assert.equal(out.size, 0);
135
+ assert.equal(q.calls.length, 0);
136
+ });
137
+
115
138
  test('taskDiscipline reads the CORE tasks table by id', async () => {
116
139
  const q = fake([{ rows: [{ discipline: 'engineer' }], rowCount: 1 }]);
117
140
  assert.equal(await db.taskDiscipline('1234', q), 'engineer');
@@ -78,8 +78,11 @@ await test('a malformed name in the stored set is never relayed', () => {
78
78
  // ---- the whole path: row → real port → real Conductor -----------------------
79
79
  const store = { adoptions: [] };
80
80
  db.listAdoptions = async () => store.adoptions.map((a) => ({ ...a }));
81
- db.countTraining = async () => ({ documentCount: 0, learningCount: 0 });
82
- db.getSpeciality = async (id) => ({ id, owner_kind: 'builder', owner_builder_id: 1 });
81
+ // The port's ONE batched read (task 1004923); the per-row pair it replaced must
82
+ // never be reached again, so those throw rather than answer.
83
+ db.describeTrainingFor = async (ids) => new Map(ids.map((id) => [String(id), { owner_kind: 'builder', owner_builder_id: 1, documentCount: 0, learningCount: 0 }]));
84
+ db.countTraining = async () => { throw new Error('per-row countTraining on the GET /me path'); };
85
+ db.getSpeciality = async () => { throw new Error('per-row getSpeciality on the GET /me path'); };
83
86
  skills.installedSkillsCached = () => new Map([['recall', {}], ['status', {}]]);
84
87
  require('../modules/specialities/routes/specialities.js')();
85
88
  const port = api.resolveOptional('specialities');
@@ -147,4 +150,47 @@ await test('no permission or materialisation surface reads enabled_skills', () =
147
150
  }
148
151
  });
149
152
 
153
+ // ---- task 1004923: the GET /me read is batched, not one pair per adoption ----
154
+ await test('three active adoptions cost ONE training read, and each keeps its own owner and counts', async () => {
155
+ const saved = db.describeTrainingFor;
156
+ const calls = [];
157
+ db.describeTrainingFor = async (ids) => {
158
+ calls.push(ids.map(String));
159
+ return new Map([
160
+ ['9', { owner_kind: 'builder', owner_builder_id: 1, documentCount: 4, learningCount: 0 }],
161
+ ['10', { owner_kind: 'builder', owner_builder_id: 77, documentCount: 0, learningCount: 2 }],
162
+ // 11 has no catalogue row: counted as nothing, and not treated as self-authored.
163
+ ]);
164
+ };
165
+ try {
166
+ store.adoptions = [
167
+ row({ id: 9, name: 'Economics', contract_md: 'Mine.' }),
168
+ row({ id: 10, name: 'Law', discipline: 'engineer', contract_md: 'Theirs.' }),
169
+ row({ id: 11, name: 'Music', discipline: 'artist', contract_md: 'Gone.' }),
170
+ row({ id: 12, name: 'Old', discipline: 'ui', active: false }),
171
+ ];
172
+ const out = await port.describeActiveFor(1);
173
+ assert.equal(calls.length, 1, 'one batched read, not one per adoption');
174
+ assert.deepEqual(calls[0], ['9', '10', '11'], 'only ACTIVE adoptions are asked about');
175
+ const by = Object.fromEntries(out.map((d) => [d.name, d]));
176
+ assert.deepEqual(Object.keys(by).sort(), ['Economics', 'Law', 'Music']);
177
+ assert.match(by.Economics.contract, /4 document\(s\)/);
178
+ assert.doesNotMatch(by.Economics.contract, /stated working preferences/, 'your own speciality stays unfenced');
179
+ assert.match(by.Law.contract, /2 learning\(s\)/);
180
+ assert.match(by.Law.contract, /stated working preferences/, 'another builder\'s speciality carries its provenance');
181
+ assert.match(by.Music.contract, /stated working preferences/, 'a row the read did not return is never self-authored');
182
+ } finally { db.describeTrainingFor = saved; }
183
+ });
184
+
185
+ await test('no active adoption asks the database nothing more', async () => {
186
+ const saved = db.describeTrainingFor;
187
+ let called = 0;
188
+ db.describeTrainingFor = async () => { called++; return new Map(); };
189
+ try {
190
+ store.adoptions = [row({ active: false })];
191
+ assert.deepEqual(await port.describeActiveFor(1), []);
192
+ assert.equal(called, 0);
193
+ } finally { db.describeTrainingFor = saved; }
194
+ });
195
+
150
196
  summary();