@bongos/core 1.18.3 → 1.18.19

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 (471) hide show
  1. package/.bongos-core.json +1576 -286
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +4 -2
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +6 -1
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/migrate.sh +99 -14
  347. package/scripts/status-mirror-sync.js +114 -84
  348. package/src/bongos/api-errors.js +1 -0
  349. package/src/bongos/auth.js +447 -102
  350. package/src/bongos/db-kernel.js +27 -0
  351. package/src/bongos/db.js +39 -0
  352. package/src/bongos/module-scope-map.js +27 -1
  353. package/src/bongos/permission-path-check.js +136 -102
  354. package/src/bongos/repo-info.js +47 -11
  355. package/src/bongos/route-rank-check.js +429 -46
  356. package/src/bongos/routes/_helpers.js +12 -1
  357. package/src/bongos/routes/auth.js +219 -2
  358. package/src/bongos/routes/backup.js +58 -2
  359. package/src/bongos/routes/me.js +35 -1
  360. package/src/bongos/serve-internal.js +84 -15
  361. package/src/branding.js +32 -0
  362. package/src/instance-config.js +30 -0
  363. package/src/module-api.js +124 -1
  364. package/src/platform-server.js +86 -9
  365. package/tests/abandon_task.mjs +59 -3
  366. package/tests/api_cli_flags.mjs +53 -0
  367. package/tests/auth_page_gate.mjs +13 -0
  368. package/tests/backfill_sessions.mjs +127 -1
  369. package/tests/backup_cli.mjs +54 -0
  370. package/tests/bongos_box_connect.mjs +77 -0
  371. package/tests/box_access.mjs +130 -1
  372. package/tests/box_credential.mjs +125 -0
  373. package/tests/box_host_keys.mjs +24 -0
  374. package/tests/box_infra_resolve.mjs +149 -0
  375. package/tests/box_scope_session.mjs +6 -2
  376. package/tests/box_sync.mjs +55 -0
  377. package/tests/box_task_scope.mjs +99 -0
  378. package/tests/branding.mjs +12 -0
  379. package/tests/cf_ruleset_preflight.mjs +142 -0
  380. package/tests/cf_tunnel.mjs +37 -0
  381. package/tests/claim_gate_rebase.mjs +98 -0
  382. package/tests/clawback_confirmed_backlog.mjs +103 -0
  383. package/tests/cli_lib_formatters.mjs +20 -0
  384. package/tests/confirm_attribution.mjs +30 -46
  385. package/tests/credit_grant.mjs +198 -10
  386. package/tests/criterion_authoring_gate.mjs +272 -0
  387. package/tests/criterion_progress.mjs +33 -1
  388. package/tests/dev_lib.mjs +253 -0
  389. package/tests/devbox_artifact_gate.mjs +75 -0
  390. package/tests/devbox_dmg_backdrop.mjs +137 -0
  391. package/tests/devbox_icons.mjs +304 -0
  392. package/tests/devbox_installer_brand.mjs +232 -0
  393. package/tests/devbox_key_register.mjs +234 -0
  394. package/tests/docs_openapi_route.mjs +110 -0
  395. package/tests/doctor_hooks.mjs +141 -0
  396. package/tests/extract_json.mjs +140 -0
  397. package/tests/fitness.mjs +89 -0
  398. package/tests/gen_freshness.mjs +96 -0
  399. package/tests/goal_advisory.mjs +251 -0
  400. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  401. package/tests/goal_routes.mjs +59 -4
  402. package/tests/goal_tier.mjs +12 -3
  403. package/tests/governance_catalog.mjs +129 -0
  404. package/tests/governance_claim_eligibility.mjs +262 -0
  405. package/tests/governance_config.mjs +187 -0
  406. package/tests/governance_ownership_scope.mjs +410 -0
  407. package/tests/governance_page_gates.mjs +324 -0
  408. package/tests/governance_parity.mjs +336 -0
  409. package/tests/governance_principals.mjs +232 -0
  410. package/tests/governance_protected_surfaces.mjs +208 -0
  411. package/tests/governance_require_permission.mjs +223 -0
  412. package/tests/governance_resolver.mjs +84 -0
  413. package/tests/governance_role_sync.mjs +231 -0
  414. package/tests/governance_seed.mjs +127 -0
  415. package/tests/governance_session_scope.mjs +434 -0
  416. package/tests/governance_vanilla_boot.mjs +203 -0
  417. package/tests/grade_attribution.mjs +7 -7
  418. package/tests/hall_audit.mjs +223 -0
  419. package/tests/hall_nav.mjs +191 -0
  420. package/tests/hall_palette.mjs +206 -0
  421. package/tests/hall_signout.mjs +124 -0
  422. package/tests/hook_user_agents.mjs +10 -0
  423. package/tests/idea_triage.mjs +162 -0
  424. package/tests/idp_device_exchange.mjs +93 -0
  425. package/tests/init.mjs +51 -0
  426. package/tests/instance_config.mjs +65 -0
  427. package/tests/instance_mismatch_guard.mjs +23 -6
  428. package/tests/instance_release_config.mjs +104 -0
  429. package/tests/linkify_refs.mjs +15 -2
  430. package/tests/migrate_root_resolution.mjs +124 -1
  431. package/tests/module_contributions.mjs +55 -0
  432. package/tests/module_loader.mjs +10 -5
  433. package/tests/module_scope_active_claims.mjs +94 -0
  434. package/tests/needs_rebase.mjs +92 -0
  435. package/tests/one_active_claim_per_session.mjs +76 -0
  436. package/tests/override_attribution.mjs +10 -8
  437. package/tests/override_request_ownership.mjs +8 -4
  438. package/tests/parse_cookie_decode.mjs +42 -0
  439. package/tests/pay_on_land.mjs +129 -0
  440. package/tests/platform_boot.mjs +100 -1
  441. package/tests/post_login_landing.mjs +42 -0
  442. package/tests/preflight_checks.mjs +72 -0
  443. package/tests/provision.mjs +132 -7
  444. package/tests/publish_branch_route.mjs +53 -0
  445. package/tests/publish_reconciler.mjs +187 -2
  446. package/tests/repo_info_binding.mjs +248 -0
  447. package/tests/reward_gate.mjs +11 -2
  448. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  449. package/tests/reward_mode_surfacing.mjs +168 -0
  450. package/tests/route_permission_check.mjs +301 -0
  451. package/tests/route_rank_check.mjs +254 -12
  452. package/tests/route_validation.mjs +30 -0
  453. package/tests/sequence.mjs +267 -0
  454. package/tests/session_digest.mjs +44 -0
  455. package/tests/session_index.mjs +18 -6
  456. package/tests/session_records.mjs +39 -1
  457. package/tests/session_reward_on_land.mjs +381 -0
  458. package/tests/session_reward_reconcile.mjs +229 -0
  459. package/tests/ship_api_client_pathspec.mjs +100 -0
  460. package/tests/ship_base_flag.mjs +93 -0
  461. package/tests/ship_ci_deploy.mjs +80 -3
  462. package/tests/ship_upstream_deletion.mjs +57 -0
  463. package/tests/smoke_target.mjs +233 -0
  464. package/tests/start_env_guard.mjs +87 -0
  465. package/tests/start_rebase_warning.mjs +103 -0
  466. package/tests/status_mirror_sync.mjs +122 -0
  467. package/tests/stop_refcheck.mjs +19 -1
  468. package/tests/task_goal_link.mjs +15 -6
  469. package/tests/upgrade.mjs +139 -0
  470. package/tests/web_admission_status.mjs +162 -0
  471. package/tests/write_validation_gaps.mjs +110 -0
@@ -69,5 +69,49 @@ test('parseHookInput: partial payload (only session_id) leaves the rest for fall
69
69
  assert.equal(r.cwd, '/c'); // no cwd in payload → keeps the fallback
70
70
  });
71
71
 
72
+ // --- snippetOf must never emit a NUL (task 1002507) -------------------------
73
+ // Postgres rejects a literal NUL inside jsonb, and `detail` is stored as jsonb.
74
+ // One NUL in one snippet therefore made POST /sessions answer 500 and discarded
75
+ // the ENTIRE digest -- so the session recorded nothing and, on a cost-plus-only
76
+ // instance, earned nothing. UTF-16LE tool output is the ordinary trigger.
77
+ //
78
+ // NUL is built with String.fromCharCode(0) so this FILE stays plain ASCII: a
79
+ // literal NUL in the source would make git and grep treat it as binary.
80
+ const ship = require('../scripts/gds/ship.js');
81
+ const NUL = String.fromCharCode(0);
82
+ const hasNul = (v) => typeof v === 'string' && v.indexOf(NUL) !== -1;
83
+
84
+ test('snippetOf strips NUL from UTF-16LE-looking tool output', () => {
85
+ // `wsl --list` read as UTF-8: every other byte is a NUL.
86
+ const wsl = 'NAME'.split('').join(NUL) + NUL + ' STATE' + NUL;
87
+ const out = ship.snippetOf(wsl, []);
88
+ assert.ok(out, 'a NUL-laden string must still yield a snippet');
89
+ assert.equal(hasNul(out), false, 'snippet must carry no NUL');
90
+ assert.ok(out.includes('NAME'), 'the readable text must survive');
91
+ });
92
+
93
+ test('snippetOf leaves ordinary text unchanged apart from flattening', () => {
94
+ assert.equal(ship.snippetOf('hello world\n\nagain', []), 'hello world again');
95
+ });
96
+
97
+ test('a NUL-only string yields no snippet rather than an empty-ish one', () => {
98
+ assert.equal(ship.snippetOf(NUL + NUL + NUL, []), undefined);
99
+ });
100
+
101
+ test('buildSessionDigest yields a digest whose detail carries no NUL', () => {
102
+ // The end-to-end guarantee the route depends on: whatever the transcript
103
+ // contained, the serialised detail must be storable as jsonb.
104
+ const entries = [
105
+ { type: 'assistant', timestamp: '2026-07-26T21:18:16.356Z',
106
+ message: { model: 'claude-opus-5', usage: { input_tokens: 5, output_tokens: 7 },
107
+ content: [{ type: 'text', text: 'ran' + NUL + ' wsl --list' }] } },
108
+ { type: 'user', timestamp: '2026-07-26T21:19:00.000Z',
109
+ message: { content: [{ type: 'tool_result', content: 'N' + NUL + 'A' + NUL + 'M' + NUL + 'E' }] } },
110
+ ];
111
+ const digest = ship.buildSessionDigest(entries, {});
112
+ assert.equal(hasNul(JSON.stringify(digest.detail)), false, 'no NUL may reach the detail jsonb');
113
+ assert.ok(digest.totalTokens > 0, 'token accounting is unaffected by the strip');
114
+ });
115
+
72
116
  console.log(`\nSessionEnd session-digest (#620) tests: ${passed} passed, ${failed} failed`);
73
117
  process.exit(failed > 0 ? 1 : 0);
@@ -39,7 +39,12 @@ test('--json yields parsed entries, newest first, with date + file + title', ()
39
39
  const r = run(['--json']);
40
40
  assert.equal(r.code, 0);
41
41
  const entries = JSON.parse(r.stdout);
42
- assert.ok(entries.length > 40, 'should parse the whole session-logs corpus');
42
+ // Parses EVERY dated session-log file — corpus-agnostic (the neutral core carries no
43
+ // session logs; an instance carries many). Was a magic `> 40` that failed on a clean
44
+ // neutral-core checkout (task 1002234).
45
+ const logsDir = path.join(ROOT, 'docs', 'session-logs');
46
+ const onDiskCount = existsSync(logsDir) ? readdirSync(logsDir).filter((n) => gen.SESSION_FILE_RE.test(n)).length : 0;
47
+ assert.equal(entries.length, onDiskCount, 'parses every dated session-log file on disk');
43
48
  for (const e of entries) {
44
49
  assert.match(e.date, /^\d{4}-\d{2}-\d{2}$/, 'each entry has an ISO date');
45
50
  assert.match(e.file, /\.md$/, 'each entry references a markdown file');
@@ -55,12 +60,14 @@ test('EVERY index link resolves to a real file — the dangling-link bug is stru
55
60
  // docs/session-log-index.md is gitignored (ADR 0110) — assert on the generated index string.
56
61
  const idx = gen.renderIndex(gen.collectEntries());
57
62
  const links = [...idx.matchAll(/\]\(session-logs\/([^)]+\.md)\)/g)].map((m) => m[1]);
58
- assert.ok(links.length > 40, 'index lists the corpus');
59
63
  for (const f of links) {
60
64
  assert.ok(existsSync(path.join(ROOT, 'docs', 'session-logs', f)), `index links a missing file: ${f}`);
61
65
  }
62
- // and it covers every dated file on disk (no session silently dropped)
63
- const onDisk = readdirSync(path.join(ROOT, 'docs', 'session-logs')).filter((n) => gen.SESSION_FILE_RE.test(n));
66
+ // Covers every dated file on disk (no session silently dropped) and no more — the
67
+ // real invariant, corpus-agnostic (0 on the neutral core; was a magic `> 40` that
68
+ // failed on a clean checkout — task 1002234).
69
+ const logsDir = path.join(ROOT, 'docs', 'session-logs');
70
+ const onDisk = existsSync(logsDir) ? readdirSync(logsDir).filter((n) => gen.SESSION_FILE_RE.test(n)) : [];
64
71
  assert.equal(links.length, onDisk.length, 'index entry count matches dated files on disk');
65
72
  });
66
73
 
@@ -70,7 +77,12 @@ test('CLAUDE.md carries the bounded generated snippet between markers, links res
70
77
  assert.ok(md.includes(gen.END), 'CLAUDE.md has the snippet end marker');
71
78
  const between = md.slice(md.indexOf(gen.BEGIN), md.indexOf(gen.END));
72
79
  const links = [...between.matchAll(/\]\(docs\/session-logs\/([^)]+\.md)\)/g)].map((m) => m[1]);
73
- assert.equal(links.length, gen.SNIPPET_COUNT, `snippet is bounded to ${gen.SNIPPET_COUNT} recent sessions`);
80
+ // Bounded to at most SNIPPET_COUNT recent sessions: exactly that when the corpus is
81
+ // large enough, fewer (the whole corpus) when smaller — 0 on the neutral core, which
82
+ // carries no session logs (was a hard `=== SNIPPET_COUNT` that failed there — task 1002234).
83
+ const corpusCount = gen.collectEntries().length;
84
+ assert.equal(links.length, Math.min(corpusCount, gen.SNIPPET_COUNT),
85
+ `snippet is bounded to ${gen.SNIPPET_COUNT} recent sessions (or the whole corpus if smaller)`);
74
86
  for (const f of links) {
75
87
  assert.ok(existsSync(path.join(ROOT, 'docs', 'session-logs', f)), `snippet links a missing file: ${f}`);
76
88
  }
@@ -93,7 +105,7 @@ test('generated markdown is linkify-clean: no bare #NNN, no broken nested links'
93
105
  test('cleanTitle de-linkifies embedded refs, de-hashes bare #NNN, keeps genuine PR refs', () => {
94
106
  // embedded markdown link → its text, then bare #NNN → plain number (no nested link, linkify-clean)
95
107
  assert.equal(
96
- gen.cleanTitle('# 2026-06-06 — Box-lane verify ([#720](https://amazonprimea.com/builders#/task/720))'),
108
+ gen.cleanTitle('# 2026-06-06 — Box-lane verify ([#720](https://example.com/builders#/task/720))'),
97
109
  '2026-06-06 — Box-lane verify (720)'
98
110
  );
99
111
  // "task #727" → "task 727"
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module';
20
20
  import path from 'node:path';
21
21
 
22
22
  const require = createRequire(import.meta.url);
23
- const { scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath } = require('../scripts/gds/ship.js');
23
+ const { scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath, encodeProjectPath } = require('../scripts/gds/ship.js');
24
24
 
25
25
  // Secret-shaped fakes built at runtime — see the NOTE above.
26
26
  const FAKE = {
@@ -135,6 +135,44 @@ test('resolveTranscriptPath falls back to REPO_ROOT, else null', () => {
135
135
  assert.equal(resolveTranscriptPath(sid, ['/a', '/b'], { base, existsFn: () => false }), null);
136
136
  });
137
137
 
138
+ // ---------- encodeProjectPath (task 1002471: win32 separators) ----------
139
+ //
140
+ // Claude Code encodes the project dir by replacing every NON-ALPHANUMERIC char
141
+ // with '-' (the rule already documented + implemented in
142
+ // scripts/gds/onboarding-config.js). ship.js replaced only '/' and '.', so on
143
+ // Windows — where the separator is '\', the drive carries ':', and paths often
144
+ // contain spaces — it produced a path that cannot exist, the transcript was
145
+ // never found, no session_record was written, and the cost-plus reward could
146
+ // never book (ADR 0146 makes that the only paid stream). Every Windows ship
147
+ // paid 0. The win32 expectations below are the REAL directory names observed
148
+ // under ~/.claude/projects on a win32 box.
149
+
150
+ test('encodeProjectPath: win32 drive + backslashes + spaces', () => {
151
+ assert.equal(
152
+ encodeProjectPath('C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos'),
153
+ 'C--Users-rocke-OneDrive-Desktop-Cloud-Bongos-cloud-bongos'
154
+ );
155
+ });
156
+
157
+ test('encodeProjectPath: win32 nested worktree path', () => {
158
+ assert.equal(
159
+ encodeProjectPath('C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos\\.claude\\worktrees\\task-1002467'),
160
+ 'C--Users-rocke-OneDrive-Desktop-Cloud-Bongos-cloud-bongos--claude-worktrees-task-1002467'
161
+ );
162
+ });
163
+
164
+ test('encodeProjectPath: POSIX behaviour unchanged (regression guard)', () => {
165
+ // These are the exact shapes the pre-existing tests above and in
166
+ // tests/memory_pull.mjs pin — they must not move.
167
+ assert.equal(encodeProjectPath('/proj'), '-proj');
168
+ assert.equal(encodeProjectPath('/proj/.claude/worktrees/wt1'), '-proj--claude-worktrees-wt1');
169
+ assert.equal(encodeProjectPath('/Users/x/Desktop/Claude-OTB'), '-Users-x-Desktop-Claude-OTB');
170
+ });
171
+
172
+ test('encodeProjectPath: POSIX path containing a space', () => {
173
+ assert.equal(encodeProjectPath('/Users/x/My Project'), '-Users-x-My-Project');
174
+ });
175
+
138
176
  // ---------- buildSessionDigest ----------
139
177
 
140
178
  // A small fixture in the real Claude Code transcript shape.
@@ -0,0 +1,381 @@
1
+ // tests/session_reward_on_land.mjs
2
+ //
3
+ // Regression guard for ADR 0120 "pay on land" — the SESSION-TOKEN reward half
4
+ // (the per-task credit half is pinned by tests/pay_on_land.mjs +
5
+ // tests/confirm_attribution.mjs). The cost-plus session token reward
6
+ // (reason='session.token_reward', task 1005/ADR 0054) must book only when the
7
+ // code LANDS (a task the session shipped reaches status='shipped'), never at
8
+ // upload time — so a session that strands at 'confirmed' collects nothing until
9
+ // its code is on `main`.
10
+ //
11
+ // The design is a DUAL TRIGGER, both calling the SAME idempotent
12
+ // awardSessionTokenReward (exactly-once via per-session_id idempotency):
13
+ // 1. UPLOAD side — db.upsertSessionRecordWithReward awards inside the upload txn
14
+ // ONLY IF a task the session shipped is already status='shipped' (the
15
+ // sync/laptop order: land, then upload). Otherwise it DEFERS.
16
+ // 2. LAND side — db.awardDeferredSessionRewardForTask(taskId), driven by
17
+ // routes/sessions.js's task.shipped listener, books the reward for every
18
+ // session that shipped that task (the ci/async order: upload, then land).
19
+ //
20
+ // The DB-free layer drives both db functions with a SQL-aware fake client:
21
+ // upsertSessionRecordWithReward / awardDeferredSessionRewardForTask call
22
+ // api.withTx(fn), so we patch api.withTx to hand fn our fake client, and register
23
+ // the REAL economy `reward` port so the real awardSessionTokenReward SQL runs
24
+ // against the fake (the confirm_attribution.mjs pattern). A guarded real-DB
25
+ // block then proves the actual uniq_credit_session_token_reward_basis partial
26
+ // index enforces exactly-once end-to-end, and that a costlier later ship tops up.
27
+ //
28
+ // Run: node tests/session_reward_on_land.mjs
29
+
30
+ import { strict as assert } from 'node:assert';
31
+ import { createRequire } from 'node:module';
32
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
33
+
34
+ process.env.NODE_ENV = 'test';
35
+
36
+ // The watermark idempotency SELECT (task 1002474) — replaced the old `SELECT 1 …`
37
+ // existence probe, so the fakes must answer with { paid, legacy }.
38
+ const WATERMARK_RE = /COALESCE\(SUM\(delta\)/;
39
+
40
+ const require = createRequire(import.meta.url);
41
+ // The session db slice resolves the economy `reward` kernel port; register it
42
+ // (real provider) so awardSessionTokenReward's real SQL runs against our fake.
43
+ const seams = require('../src/module-seams.js');
44
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
45
+ const api = require('../src/module-api.js');
46
+ const db = require('../modules/sessions/db.js');
47
+
48
+ const { test, summary } = makeRunner();
49
+
50
+ // Opus 110k input tokens → $0.55 true cost → $0.55 × 1.20 = 1 drachma (the same
51
+ // priceable fixture credit_grant.mjs uses). A non-zero payout is what lets the
52
+ // idempotency SELECT even run (awardSessionTokenReward short-circuits at $0).
53
+ const MODEL_USAGE = { 'claude-opus-4-8': { input: 110_000 } };
54
+
55
+ const norm = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
56
+ const find = (queries, re) => queries.find((q) => re.test(norm(q)));
57
+ const all = (queries, re) => queries.filter((q) => re.test(norm(q)));
58
+
59
+ // Drive a db function that opens api.withTx(fn) against a fake client whose
60
+ // query() answers are dictated by `handler(normalizedSql)`. Returns the db
61
+ // function's result + the recorded { sql, params } call log. Restores withTx.
62
+ async function withFakeTx(handler, run) {
63
+ const fake = makeSqlAwareClient(handler);
64
+ const prev = api.withTx;
65
+ api.withTx = (fn) => fn(fake._client);
66
+ try {
67
+ const result = await run();
68
+ return { result, queries: fake.queries };
69
+ } finally {
70
+ api.withTx = prev;
71
+ }
72
+ }
73
+
74
+ // A session_records row as upsertSessionRecordTx's INSERT ... RETURNING yields it.
75
+ function upsertReturns(sessionId, taskIds) {
76
+ return {
77
+ rows: [{
78
+ id: 101, session_id: sessionId, builder_id: 7, task_ids: taskIds,
79
+ drachmae_earned: 0, total_tokens: 5000, duration_seconds: 60,
80
+ uploaded_at: '2026-07-15T00:00:00Z',
81
+ }],
82
+ };
83
+ }
84
+
85
+ // =========================================================================
86
+ // (1) UPLOAD side — DEFER when no shipped task (ci/async order)
87
+ // =========================================================================
88
+
89
+ await test('upsert: session task NOT shipped → NO reward INSERT (deferred), tokenReward.awarded === false', async () => {
90
+ const { result, queries } = await withFakeTx(
91
+ (s) => {
92
+ if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-defer', [660]);
93
+ if (/SELECT 1 FROM tasks WHERE id = ANY/.test(s)) return { rows: [] }; // not landed yet
94
+ return null;
95
+ },
96
+ () => db.upsertSessionRecordWithReward({
97
+ sessionId: 'sess-defer', builderId: 7, taskIds: [660],
98
+ totalTokens: 5000, modelUsage: MODEL_USAGE,
99
+ })
100
+ );
101
+
102
+ // The land gate ran...
103
+ assert.ok(find(queries, /SELECT 1 FROM tasks WHERE id = ANY.*status = 'shipped'/),
104
+ 'the pay-on-land gate (tasks status=shipped) must be consulted');
105
+ // ...found nothing shipped, so NO reward was booked.
106
+ assert.equal(all(queries, /INSERT INTO credit_log/).length, 0,
107
+ 'a session with no landed task must NOT book a token reward at upload');
108
+ assert.equal(result.tokenReward.awarded, false, 'awarded must be false when deferred');
109
+ assert.equal(result.tokenReward.deferred, true, 'the deferred flag must be set');
110
+ // The digest itself is still recorded (the upsert ran + returned the row).
111
+ assert.ok(find(queries, /^INSERT INTO session_records/), 'the digest upsert still runs');
112
+ assert.equal(result.id, 101, 'the record is returned even when the reward defers');
113
+ });
114
+
115
+ await test('upsert: session with NO task_ids → deferred, gate not even queried', async () => {
116
+ const { result, queries } = await withFakeTx(
117
+ (s) => {
118
+ if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-none', []);
119
+ return null;
120
+ },
121
+ () => db.upsertSessionRecordWithReward({
122
+ sessionId: 'sess-none', builderId: 7, taskIds: [],
123
+ totalTokens: 5000, modelUsage: MODEL_USAGE,
124
+ })
125
+ );
126
+ assert.equal(all(queries, /INSERT INTO credit_log/).length, 0, 'no tasks → no reward');
127
+ assert.ok(!find(queries, /FROM tasks WHERE id = ANY/), 'no task_ids → skip the gate query entirely');
128
+ assert.equal(result.tokenReward.awarded, false);
129
+ });
130
+
131
+ // =========================================================================
132
+ // (2) UPLOAD side — AWARD when a task is already shipped (sync/laptop order)
133
+ // =========================================================================
134
+
135
+ await test('upsert: a task already shipped → awardSessionTokenReward runs (a session.token_reward INSERT)', async () => {
136
+ const { result, queries } = await withFakeTx(
137
+ (s) => {
138
+ if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-land', [660]);
139
+ if (/SELECT 1 FROM tasks WHERE id = ANY/.test(s)) return { rows: [{ '?column?': 1 }] }; // landed
140
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] }; // nothing paid yet
141
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
142
+ return null;
143
+ },
144
+ () => db.upsertSessionRecordWithReward({
145
+ sessionId: 'sess-land', builderId: 7, taskIds: [660],
146
+ totalTokens: 5000, modelUsage: MODEL_USAGE,
147
+ })
148
+ );
149
+
150
+ const insert = find(queries, /^INSERT INTO credit_log/);
151
+ assert.ok(insert, 'a credit_log INSERT must run once a task has landed');
152
+ assert.match(norm(insert), /session\.token_reward/, 'the reward reason must be session.token_reward');
153
+ // params: [builderId, drachmae, description, sessionId]
154
+ assert.equal(insert.params[0], 7, 'reward attributed to the session owner');
155
+ assert.equal(insert.params[1], 1, '$0.55 × 1.20 → 1 drachma');
156
+ assert.equal(insert.params[3], 'sess-land', 'idempotency key is the session_id');
157
+ assert.equal(result.tokenReward.awarded, true);
158
+ assert.equal(result.tokenReward.drachmae, 1);
159
+ // observability mirror onto session_records.token_reward_drachmae
160
+ assert.ok(find(queries, /^UPDATE session_records SET token_reward_drachmae/), 'mirror UPDATE runs on a fresh award');
161
+ });
162
+
163
+ // =========================================================================
164
+ // (3) LAND side — awardDeferredSessionRewardForTask books the deferred reward
165
+ // =========================================================================
166
+
167
+ await test('awardDeferredSessionRewardForTask: session with the task + priceable usage → awards (INSERT for the session builder)', async () => {
168
+ const { result, queries } = await withFakeTx(
169
+ (s) => {
170
+ if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) {
171
+ return { rows: [{ session_id: 'sess-ci', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
172
+ }
173
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] }; // nothing paid yet
174
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 2 }] };
175
+ return null;
176
+ },
177
+ () => db.awardDeferredSessionRewardForTask(660)
178
+ );
179
+
180
+ // The lookup is by task_ids array containment (the shipped task's id).
181
+ assert.ok(find(queries, /FROM session_records WHERE task_ids @> ARRAY/),
182
+ 'sessions are found by task_ids containment of the shipped task');
183
+ const insert = find(queries, /^INSERT INTO credit_log/);
184
+ assert.ok(insert, 'a session.token_reward INSERT must run for the deferred session');
185
+ assert.match(norm(insert), /session\.token_reward/);
186
+ assert.equal(insert.params[0], 7, "attributed to the session record's builder_id");
187
+ assert.equal(insert.params[3], 'sess-ci', 'keyed on the session_id');
188
+ assert.equal(result.sessions, 1, 'one session scanned');
189
+ assert.equal(result.awarded, 1, 'one reward freshly booked');
190
+ });
191
+
192
+ await test('awardDeferredSessionRewardForTask: prior reward exists (idempotency SELECT returns a row) → NO second INSERT', async () => {
193
+ const { result, queries } = await withFakeTx(
194
+ (s) => {
195
+ if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) {
196
+ return { rows: [{ session_id: 'sess-ci', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
197
+ }
198
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 999_999, legacy: 0 }] }; // ALREADY PAID IN FULL
199
+ return null;
200
+ },
201
+ () => db.awardDeferredSessionRewardForTask(660)
202
+ );
203
+
204
+ assert.ok(find(queries, WATERMARK_RE),
205
+ 'the per-session watermark guard must be consulted');
206
+ assert.equal(all(queries, /INSERT INTO credit_log/).length, 0, 're-firing on a rewarded session is a no-op');
207
+ assert.ok(!find(queries, /^UPDATE session_records SET token_reward_drachmae/), 'no mirror UPDATE when nothing was booked');
208
+ assert.equal(result.sessions, 1);
209
+ assert.equal(result.awarded, 0, 'nothing freshly booked');
210
+ });
211
+
212
+ await test('awardDeferredSessionRewardForTask: no session shipped this task → clean no-op', async () => {
213
+ const { result, queries } = await withFakeTx(
214
+ (s) => {
215
+ if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) return { rows: [] };
216
+ return null;
217
+ },
218
+ () => db.awardDeferredSessionRewardForTask(424242)
219
+ );
220
+ assert.equal(all(queries, /INSERT INTO credit_log/).length, 0);
221
+ assert.equal(result.sessions, 0);
222
+ assert.equal(result.awarded, 0);
223
+ });
224
+
225
+ await test('awardDeferredSessionRewardForTask: non-numeric id → immediate no-op (no DB touch)', async () => {
226
+ // No withTx patch — a bad id must return before opening a transaction.
227
+ const out = await db.awardDeferredSessionRewardForTask('not-a-number');
228
+ assert.deepEqual(out, { sessions: 0, awarded: 0 });
229
+ });
230
+
231
+ // =========================================================================
232
+ // (4) REAL-DB — the actual uniq_credit_session_token_reward partial index
233
+ // enforces exactly-once across the two triggers. Guarded on DATABASE_URL
234
+ // (env.sh sets it); skipped cleanly otherwise.
235
+ // =========================================================================
236
+
237
+ if (process.env.DATABASE_URL) {
238
+ const pool = api.pool;
239
+ const suffix = `srol_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
240
+ const ctx = { builderId: null, versionId: `ver_${suffix}`, taskId: null };
241
+ let setupOk = false;
242
+ try {
243
+ const b = await pool.query(
244
+ `INSERT INTO builders (github_id, github_login, display_name) VALUES ($1,$2,$3) RETURNING id`,
245
+ [`gh_${suffix}`, `login_${suffix}`, `Test ${suffix}`]
246
+ );
247
+ ctx.builderId = Number(b.rows[0].id);
248
+ await pool.query(
249
+ `INSERT INTO versions (id, name, track, status) VALUES ($1,$2,'internal','planning')`,
250
+ [ctx.versionId, `V ${suffix}`]
251
+ );
252
+ const tk = await pool.query(
253
+ `INSERT INTO tasks (version_id, title, status) VALUES ($1,$2,'shipped') RETURNING id`,
254
+ [ctx.versionId, `shipped task ${suffix}`]
255
+ );
256
+ ctx.taskId = Number(tk.rows[0].id);
257
+ setupOk = true;
258
+ } catch (e) {
259
+ console.log(` SKIP real-DB block (setup failed: ${e.message})`);
260
+ }
261
+
262
+ if (setupOk) {
263
+ const rewardRows = (sessionId) => pool.query(
264
+ `SELECT delta FROM credit_log WHERE session_id = $1 AND reason = 'session.token_reward'`,
265
+ [sessionId]
266
+ );
267
+ try {
268
+ await test('real-DB: awardDeferredSessionRewardForTask books once + is idempotent (partial index)', async () => {
269
+ const sid = `sess-deferred-${suffix}`;
270
+ await pool.query(
271
+ `INSERT INTO session_records (session_id, builder_id, task_ids, total_tokens, model_usage)
272
+ VALUES ($1, $2, ARRAY[$3]::bigint[], $4, $5::jsonb)`,
273
+ [sid, ctx.builderId, ctx.taskId, 5000, JSON.stringify(MODEL_USAGE)]
274
+ );
275
+
276
+ const first = await db.awardDeferredSessionRewardForTask(ctx.taskId);
277
+ assert.equal(first.awarded, 1, 'first land books the reward');
278
+ let rows = (await rewardRows(sid)).rows;
279
+ assert.equal(rows.length, 1, 'exactly one session.token_reward row');
280
+ assert.ok(rows[0].delta > 0, 'a positive drachmae payout landed');
281
+
282
+ const mirror = await pool.query(
283
+ `SELECT token_reward_drachmae FROM session_records WHERE session_id = $1`, [sid]
284
+ );
285
+ assert.ok(Number(mirror.rows[0].token_reward_drachmae) > 0, 'the drachmae is mirrored onto the row');
286
+
287
+ const second = await db.awardDeferredSessionRewardForTask(ctx.taskId);
288
+ assert.equal(second.awarded, 0, 're-firing the land event is a no-op');
289
+ rows = (await rewardRows(sid)).rows;
290
+ assert.equal(rows.length, 1, 'the partial-unique index held — still exactly one row, no double payout');
291
+ });
292
+
293
+ // task 1002474 — the whole point of the watermark: a session that keeps
294
+ // spending after its first ship must be paid the difference, and the real
295
+ // (session_id, reward_basis) index must permit exactly that second row while
296
+ // still refusing a duplicate at the same basis.
297
+ await test('real-DB: a costlier later ship TOPS UP, and the top-up is itself idempotent', async () => {
298
+ const sid = `sess-topup-${suffix}`;
299
+ await pool.query(
300
+ `INSERT INTO session_records (session_id, builder_id, task_ids, total_tokens, model_usage)
301
+ VALUES ($1, $2, ARRAY[$3]::bigint[], $4, $5::jsonb)`,
302
+ [sid, ctx.builderId, ctx.taskId, 5000, JSON.stringify(MODEL_USAGE)]
303
+ );
304
+ await db.awardDeferredSessionRewardForTask(ctx.taskId);
305
+ const afterFirst = (await rewardRows(sid)).rows;
306
+ assert.equal(afterFirst.length, 1, 'the first ship books one row');
307
+ const firstPaid = Number(afterFirst[0].delta);
308
+
309
+ // The session keeps working and gets much more expensive, then ships again.
310
+ const bigger = {};
311
+ for (const [model, usage] of Object.entries(MODEL_USAGE)) {
312
+ bigger[model] = Object.fromEntries(
313
+ Object.entries(usage).map(([k, v]) => [k, Number(v) * 40])
314
+ );
315
+ }
316
+ await pool.query(
317
+ `UPDATE session_records SET model_usage = $2::jsonb WHERE session_id = $1`,
318
+ [sid, JSON.stringify(bigger)]
319
+ );
320
+
321
+ const topup = await db.awardDeferredSessionRewardForTask(ctx.taskId);
322
+ assert.equal(topup.awarded, 1, 'the grown session books a second row');
323
+ const afterTopup = (await rewardRows(sid)).rows;
324
+ assert.equal(afterTopup.length, 2, 'the (session_id, reward_basis) index PERMITS the increment');
325
+ const total = afterTopup.reduce((n, r) => n + Number(r.delta), 0);
326
+ assert.ok(total > firstPaid, 'the builder is now paid more than the first ship alone');
327
+ assert.ok(
328
+ afterTopup.every((r) => Number(r.delta) > 0),
329
+ 'every row is a positive credit — a top-up never claws back'
330
+ );
331
+
332
+ // Re-firing at the SAME (unchanged) cost must add nothing.
333
+ const again = await db.awardDeferredSessionRewardForTask(ctx.taskId);
334
+ assert.equal(again.awarded, 0, 'no further payment when nothing changed');
335
+ assert.equal(
336
+ (await rewardRows(sid)).rows.length, 2,
337
+ 'the index still refuses a duplicate at the same basis — exactly-once per level'
338
+ );
339
+ });
340
+
341
+ await test('real-DB: upsertSessionRecordWithReward DEFERS when the task is not shipped', async () => {
342
+ const sid = `sess-upload-defer-${suffix}`;
343
+ // A task id that is not status=shipped (nonexistent id → gate finds nothing).
344
+ const out = await db.upsertSessionRecordWithReward({
345
+ sessionId: sid, builderId: ctx.builderId, taskIds: [999_000_000 + Math.floor(Math.random() * 1000)],
346
+ totalTokens: 5000, modelUsage: MODEL_USAGE,
347
+ });
348
+ assert.equal(out.tokenReward.awarded, false, 'no landed task → deferred');
349
+ assert.equal(out.tokenReward.deferred, true);
350
+ assert.equal((await rewardRows(sid)).rows.length, 0, 'no reward row booked at upload');
351
+ });
352
+
353
+ await test('real-DB: upsertSessionRecordWithReward AWARDS when a task is already shipped', async () => {
354
+ const sid = `sess-upload-award-${suffix}`;
355
+ const out = await db.upsertSessionRecordWithReward({
356
+ sessionId: sid, builderId: ctx.builderId, taskIds: [ctx.taskId],
357
+ totalTokens: 5000, modelUsage: MODEL_USAGE,
358
+ });
359
+ assert.equal(out.tokenReward.awarded, true, 'a shipped task in the session → award at upload');
360
+ assert.ok(out.tokenReward.drachmae > 0);
361
+ assert.equal((await rewardRows(sid)).rows.length, 1, 'exactly one reward row');
362
+ });
363
+ } finally {
364
+ // FK-safe teardown (credit_log + session_records → builder; tasks → version).
365
+ try {
366
+ await pool.query(`DELETE FROM credit_log WHERE builder_id = $1`, [ctx.builderId]);
367
+ await pool.query(`DELETE FROM session_records WHERE builder_id = $1`, [ctx.builderId]);
368
+ await pool.query(`DELETE FROM tasks WHERE version_id = $1`, [ctx.versionId]);
369
+ await pool.query(`DELETE FROM versions WHERE id = $1`, [ctx.versionId]);
370
+ await pool.query(`DELETE FROM builders WHERE id = $1`, [ctx.builderId]);
371
+ } catch (e) {
372
+ console.log(` WARN real-DB teardown left rows (${e.message})`);
373
+ }
374
+ }
375
+ }
376
+ } else {
377
+ console.log(' SKIP real-DB block (no DATABASE_URL)');
378
+ }
379
+
380
+ // -------------------------------------------------------------------------
381
+ summary();