@bongos/core 1.18.3 → 1.18.18

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 (469) hide show
  1. package/.bongos-core.json +1575 -285
  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/status-mirror-sync.js +114 -84
  347. package/src/bongos/api-errors.js +1 -0
  348. package/src/bongos/auth.js +447 -102
  349. package/src/bongos/db-kernel.js +27 -0
  350. package/src/bongos/db.js +39 -0
  351. package/src/bongos/module-scope-map.js +27 -1
  352. package/src/bongos/permission-path-check.js +136 -102
  353. package/src/bongos/repo-info.js +47 -11
  354. package/src/bongos/route-rank-check.js +429 -46
  355. package/src/bongos/routes/_helpers.js +12 -1
  356. package/src/bongos/routes/auth.js +219 -2
  357. package/src/bongos/routes/backup.js +58 -2
  358. package/src/bongos/routes/me.js +35 -1
  359. package/src/bongos/serve-internal.js +84 -15
  360. package/src/branding.js +32 -0
  361. package/src/instance-config.js +30 -0
  362. package/src/module-api.js +112 -1
  363. package/src/platform-server.js +86 -9
  364. package/tests/abandon_task.mjs +59 -3
  365. package/tests/api_cli_flags.mjs +53 -0
  366. package/tests/auth_page_gate.mjs +13 -0
  367. package/tests/backfill_sessions.mjs +127 -1
  368. package/tests/backup_cli.mjs +54 -0
  369. package/tests/bongos_box_connect.mjs +77 -0
  370. package/tests/box_access.mjs +130 -1
  371. package/tests/box_credential.mjs +125 -0
  372. package/tests/box_host_keys.mjs +24 -0
  373. package/tests/box_infra_resolve.mjs +149 -0
  374. package/tests/box_scope_session.mjs +6 -2
  375. package/tests/box_sync.mjs +55 -0
  376. package/tests/box_task_scope.mjs +99 -0
  377. package/tests/branding.mjs +12 -0
  378. package/tests/cf_ruleset_preflight.mjs +142 -0
  379. package/tests/cf_tunnel.mjs +37 -0
  380. package/tests/claim_gate_rebase.mjs +98 -0
  381. package/tests/clawback_confirmed_backlog.mjs +103 -0
  382. package/tests/cli_lib_formatters.mjs +20 -0
  383. package/tests/confirm_attribution.mjs +30 -46
  384. package/tests/credit_grant.mjs +198 -10
  385. package/tests/criterion_authoring_gate.mjs +272 -0
  386. package/tests/criterion_progress.mjs +33 -1
  387. package/tests/dev_lib.mjs +253 -0
  388. package/tests/devbox_artifact_gate.mjs +75 -0
  389. package/tests/devbox_dmg_backdrop.mjs +137 -0
  390. package/tests/devbox_icons.mjs +304 -0
  391. package/tests/devbox_installer_brand.mjs +232 -0
  392. package/tests/devbox_key_register.mjs +234 -0
  393. package/tests/docs_openapi_route.mjs +110 -0
  394. package/tests/doctor_hooks.mjs +141 -0
  395. package/tests/extract_json.mjs +140 -0
  396. package/tests/fitness.mjs +89 -0
  397. package/tests/gen_freshness.mjs +96 -0
  398. package/tests/goal_advisory.mjs +251 -0
  399. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  400. package/tests/goal_routes.mjs +59 -4
  401. package/tests/goal_tier.mjs +12 -3
  402. package/tests/governance_catalog.mjs +129 -0
  403. package/tests/governance_claim_eligibility.mjs +262 -0
  404. package/tests/governance_config.mjs +187 -0
  405. package/tests/governance_ownership_scope.mjs +410 -0
  406. package/tests/governance_page_gates.mjs +324 -0
  407. package/tests/governance_parity.mjs +336 -0
  408. package/tests/governance_principals.mjs +232 -0
  409. package/tests/governance_protected_surfaces.mjs +208 -0
  410. package/tests/governance_require_permission.mjs +223 -0
  411. package/tests/governance_resolver.mjs +84 -0
  412. package/tests/governance_role_sync.mjs +231 -0
  413. package/tests/governance_seed.mjs +127 -0
  414. package/tests/governance_session_scope.mjs +434 -0
  415. package/tests/governance_vanilla_boot.mjs +203 -0
  416. package/tests/grade_attribution.mjs +7 -7
  417. package/tests/hall_audit.mjs +223 -0
  418. package/tests/hall_nav.mjs +191 -0
  419. package/tests/hall_palette.mjs +206 -0
  420. package/tests/hall_signout.mjs +124 -0
  421. package/tests/hook_user_agents.mjs +10 -0
  422. package/tests/idea_triage.mjs +162 -0
  423. package/tests/idp_device_exchange.mjs +93 -0
  424. package/tests/init.mjs +51 -0
  425. package/tests/instance_config.mjs +65 -0
  426. package/tests/instance_mismatch_guard.mjs +23 -6
  427. package/tests/instance_release_config.mjs +104 -0
  428. package/tests/linkify_refs.mjs +15 -2
  429. package/tests/module_contributions.mjs +55 -0
  430. package/tests/module_loader.mjs +10 -5
  431. package/tests/module_scope_active_claims.mjs +94 -0
  432. package/tests/needs_rebase.mjs +92 -0
  433. package/tests/one_active_claim_per_session.mjs +76 -0
  434. package/tests/override_attribution.mjs +10 -8
  435. package/tests/override_request_ownership.mjs +8 -4
  436. package/tests/parse_cookie_decode.mjs +42 -0
  437. package/tests/pay_on_land.mjs +129 -0
  438. package/tests/platform_boot.mjs +100 -1
  439. package/tests/post_login_landing.mjs +42 -0
  440. package/tests/preflight_checks.mjs +72 -0
  441. package/tests/provision.mjs +132 -7
  442. package/tests/publish_branch_route.mjs +53 -0
  443. package/tests/publish_reconciler.mjs +187 -2
  444. package/tests/repo_info_binding.mjs +248 -0
  445. package/tests/reward_gate.mjs +11 -2
  446. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  447. package/tests/reward_mode_surfacing.mjs +168 -0
  448. package/tests/route_permission_check.mjs +301 -0
  449. package/tests/route_rank_check.mjs +254 -12
  450. package/tests/route_validation.mjs +30 -0
  451. package/tests/sequence.mjs +267 -0
  452. package/tests/session_digest.mjs +44 -0
  453. package/tests/session_index.mjs +18 -6
  454. package/tests/session_records.mjs +39 -1
  455. package/tests/session_reward_on_land.mjs +381 -0
  456. package/tests/session_reward_reconcile.mjs +229 -0
  457. package/tests/ship_api_client_pathspec.mjs +100 -0
  458. package/tests/ship_base_flag.mjs +93 -0
  459. package/tests/ship_ci_deploy.mjs +80 -3
  460. package/tests/ship_upstream_deletion.mjs +57 -0
  461. package/tests/smoke_target.mjs +233 -0
  462. package/tests/start_env_guard.mjs +87 -0
  463. package/tests/start_rebase_warning.mjs +103 -0
  464. package/tests/status_mirror_sync.mjs +122 -0
  465. package/tests/stop_refcheck.mjs +19 -1
  466. package/tests/task_goal_link.mjs +15 -6
  467. package/tests/upgrade.mjs +139 -0
  468. package/tests/web_admission_status.mjs +162 -0
  469. package/tests/write_validation_gaps.mjs +110 -0
@@ -34,6 +34,15 @@ const { pool, withTx, insertAuditLog, LIVE_RANK_LADDER } = api;
34
34
  // so the module reaches the `reward`/`onboarding` ports + emits kernel events
35
35
  // (task.shipped, builder.box-reconcile-needed) without importing core.
36
36
  const seams = api;
37
+ // Sibling read-model helper (task 1002164) — the shared pending-review predicate.
38
+ // done-when.js imports only src/module-api, so this stays acyclic.
39
+ const doneWhen = require('./done-when.js');
40
+ // The catch-all goal title, shared with the orphan advisory (task 1002539) so the
41
+ // SQL below and the detector that names the same goal cannot drift apart.
42
+ const { generalGoalTitle } = require('./goal-advisory.js');
43
+ // BV1.R106 (ADR 0151): the permission-expressed QUEUE axis of claim eligibility.
44
+ // Kept deliberately separate from the per-task requires_rank floor below.
45
+ const { claimEligibilityAllows } = require('./claim-eligibility.js');
37
46
  // Pure kernel-adjacent matchers via the doorway (never the core file).
38
47
  const { matchProtected } = api.permissionPathCheck;
39
48
  const { touchesOverlap } = api.pathMatch;
@@ -164,11 +173,46 @@ const GENERAL_QUEUE_RANKS = new Set(['thetes', 'metic', 'archon']);
164
173
  // Pure + exported so tests/rank_gate.mjs can exercise it without a DB; claimTask
165
174
  // + claimTasksBatch both call it (composed with rankAllowsTask).
166
175
 
176
+ // BV1.R106 (ADR 0151): this rank rule is no longer the primary gate — the claim
177
+ // paths decide the queue axis on the PERMISSIONS `task.claim.any` /
178
+ // `task.claim.newcomer` (claim-eligibility.js) and keep this as the fallback for a
179
+ // builder whose role row the run-once compat backfill never created. It stays
180
+ // exported: tests/rank_gate.mjs pins it, and it is the oracle the parity test in
181
+ // tests/governance_claim_eligibility.mjs compares the permission predicate against.
182
+
167
183
  function xenosClaimAllowed(rank, newcomerFriendly) {
168
184
  if (GENERAL_QUEUE_RANKS.has(rank)) return true;
169
185
  return newcomerFriendly === true;
170
186
  }
171
187
 
188
+ // claimPermissionsFor — the builder's effective permission set for the claim
189
+ // decision, read through the `governance` kernel PORT (never by importing the
190
+ // module — the one-way rule, ADR 0083). Returns a Set, or NULL when authority
191
+ // cannot be ESTABLISHED, which claimEligibilityAllows reads as "fall back to the
192
+ // incumbent rank rule":
193
+ // • the governance module is not mounted (no port), or the resolver throws;
194
+ // • the builder resolves to ZERO permissions — i.e. they hold no role row.
195
+ // governance_002's backfill is run-once and nothing yet re-assigns a rank-role
196
+ // when a builder is created or re-ranked, so this is a live gap, not a theory.
197
+ // Every seeded rank-role grants at least twelve permissions, so an empty set never
198
+ // means "a real role that grants nothing" today.
199
+ //
200
+ // Called BEFORE the claim transaction opens: authority is a per-request read (ADR
201
+ // 0016 — resolved server-side, uncached), and taking a second pool connection while
202
+ // a tx holds FOR UPDATE locks is a needless deadlock/exhaustion hazard.
203
+ async function claimPermissionsFor(builderId) {
204
+ const governance = seams.resolveOptional('governance');
205
+ if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
206
+ try {
207
+ const keys = await governance.resolveBuilderPermissions(builderId);
208
+ if (!keys || keys.length === 0) return null;
209
+ return new Set(keys);
210
+ } catch (err) {
211
+ console.error('[gds] claim eligibility: permission resolve failed, falling back to the rank rule', err);
212
+ return null;
213
+ }
214
+ }
215
+
172
216
  // V3.R82 (#301): the INVERSE of newcomer_friendly. Some tasks raise the floor
173
217
  // instead of opening the door — `requires_rank` is a per-task MINIMUM rank
174
218
  // that a builder must meet to claim. Default 'xenos' means "no extra gate"
@@ -318,6 +362,24 @@ function disambiguateWorktreeName(desired, taken) {
318
362
  //
319
363
  // Returns the updated builder row, or throws { code:'BUILDER_NOT_FOUND' } /
320
364
  // { code:'RANK_UNCHANGED' }.
365
+ // syncGovernanceRankRole — keep governance_builder_roles in step with builders.rank
366
+ // (BV1.R95b, ADR 0151). This module's rank writer backs the automatic xenos→thetes
367
+ // graduation, which had the same hole as the Archon-driven path: the rank moved and
368
+ // the role did not, so a freshly-graduated Thetes kept the xenos role.
369
+ //
370
+ // Resolved through the `governance.roleSync` kernel port (never a sibling require —
371
+ // ADR 0083) and run on the CALLER's transaction client, so the rotation commits
372
+ // atomically with the ship + graduation. A missing port (governance disabled) skips:
373
+ // no role ⇒ zero permissions, fail-closed.
374
+ //
375
+ // TWINNED with src/bongos/db.js (the boundary-forced _applyRankChangeTx duplication,
376
+ // ADR 0091 §2) — keep the two in sync.
377
+ async function syncGovernanceRankRole(client, builderId, rank) {
378
+ const roleSync = seams.resolveOptional('governance.roleSync');
379
+ if (!roleSync || typeof roleSync.syncRankRole !== 'function') return null;
380
+ return roleSync.syncRankRole(client, { builderId, rank });
381
+ }
382
+
321
383
  // _applyRankChangeTx — the in-transaction CORE of a rank change, shared by the
322
384
  // Archon-driven setBuilderRank and the automatic xenos→thetes graduation in the
323
385
  // confirm paths (#692 / ADR 0034). Runs inside the CALLER's transaction/client
@@ -326,7 +388,6 @@ function disambiguateWorktreeName(desired, taken) {
326
388
  // — on demotion — revokes live sessions so a stale token can't replay old
327
389
  // authority. Returns the audit shape (incl. the builder's `status`, so the
328
390
  // caller can fire the post-commit box reconcile on its OWN connection).
329
-
330
391
  async function _applyRankChangeTx(client, { builderId, newRank, actorBuilderId, reasonNote = null, enforceSingleRungPromotion = false }) {
331
392
  const { rows } = await client.query(
332
393
  `SELECT id, rank, status FROM builders WHERE id = $1 FOR UPDATE`,
@@ -351,6 +412,12 @@ async function _applyRankChangeTx(client, { builderId, newRank, actorBuilderId,
351
412
  [builderId, newRank]
352
413
  );
353
414
 
415
+ // BV1.R95b — rotate the seeded rank-role IN THIS TRANSACTION (removing the old one,
416
+ // not merely adding the new). Not try/catch-wrapped: a failure must roll the rank
417
+ // change back rather than leave rank and role disagreeing. See the twin in
418
+ // src/bongos/db.js.
419
+ await syncGovernanceRankRole(client, builderId, newRank);
420
+
354
421
  // V3.R28 / #249 — ceremony delta. Promotion (moving UP the ladder = lower
355
422
  // index in RANKS) earns +5; demotion is audit-only (0).
356
423
  const oldIdx = RANKS.indexOf(oldRank);
@@ -623,15 +690,20 @@ async function getGoalDependencies(goalId, deps = {}) {
623
690
  // by the owner's WITHIN-version priority (sort_order, task 1760), falling back
624
691
  // to created_at/id so a fresh instance and prod agree on row order pre-reorder.
625
692
 
626
- async function listGoals({ versionId = null, status = null } = {}, deps = {}) {
693
+ // `limit` is optional and defaults to unbounded — the goal board reads every goal.
694
+ // A caller that only needs the first N (the orphan advisory wants 5 candidates)
695
+ // passes it so Postgres does the truncating instead of shipping the whole version's
696
+ // goals to be discarded in JS (task 1002539).
697
+ async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
627
698
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
628
699
  const { rows } = await activePool.query(
629
700
  `SELECT ${GOAL_COLS}
630
701
  FROM goals
631
702
  WHERE ($1::text IS NULL OR version_id = $1)
632
703
  AND ($2::text IS NULL OR status = $2)
633
- ORDER BY version_id, sort_order, created_at, id`,
634
- [versionId, status]
704
+ ORDER BY version_id, sort_order, created_at, id
705
+ LIMIT $3::int`,
706
+ [versionId, status, limit]
635
707
  );
636
708
  return rows;
637
709
  }
@@ -779,14 +851,44 @@ async function listGoalMembers(goalId, deps = {}) {
779
851
 
780
852
  async function listCriteriaForGoal(goalId, deps = {}) {
781
853
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
854
+ // task 1002164: carry the linked-task counts so the caller can derive the
855
+ // "met — pending review" state the hall renders. The counts are aggregated
856
+ // ONCE, over only this goal's criteria (the `counts` CTE narrows on the `crit`
857
+ // CTE before grouping), rather than per-row correlated subqueries — one pass
858
+ // over the idx_task_criteria_criterion range instead of 2N lookups. The
859
+ // criterion columns are still listed explicitly so the projection this read
860
+ // already feeds (the version-criteria view, the task-create suggester) is
861
+ // unchanged apart from the two additive count columns.
782
862
  const { rows } = await activePool.query(
783
- `SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
784
- FROM done_when_criteria
785
- WHERE goal_id = $1
863
+ `WITH crit AS (
864
+ SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
865
+ FROM done_when_criteria
866
+ WHERE goal_id = $1
867
+ ),
868
+ counts AS (
869
+ SELECT tc.criterion_id,
870
+ count(*)::int AS linked_tasks,
871
+ count(*) FILTER (WHERE t.status <> 'shipped')::int AS unshipped_tasks
872
+ FROM task_criteria tc
873
+ JOIN tasks t ON t.id = tc.task_id
874
+ WHERE tc.criterion_id IN (SELECT id FROM crit)
875
+ GROUP BY tc.criterion_id
876
+ )
877
+ SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.satisfied,
878
+ c.sort_order, c.goal_id,
879
+ COALESCE(n.linked_tasks, 0) AS linked_tasks,
880
+ COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks
881
+ FROM crit c
882
+ LEFT JOIN counts n ON n.criterion_id = c.id
786
883
  ORDER BY sort_order, id`,
787
884
  [goalId]
788
885
  );
789
- return rows;
886
+ return rows.map((r) => ({
887
+ ...r,
888
+ pending_review: doneWhen.isPendingReview({
889
+ satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
890
+ }),
891
+ }));
790
892
  }
791
893
 
792
894
  // isGoalMember — is this builder a member of this goal? The membership predicate
@@ -1179,14 +1281,15 @@ async function listPendingRequestsForGoal(goalId, deps = {}) {
1179
1281
  // holds for every create-vector. Returns the goal id, or null when the version has
1180
1282
  // no general goal (then the caller leaves goal_id NULL rather than failing a
1181
1283
  // create). Honors the #538 deps.pool test seam (NODE_ENV==='test') so it is
1182
- // unit-testable DB-free. The title match mirrors migration 160's insert exactly
1183
- // (v.id || ' — general', a spaced em-dash) — keep the two in sync.
1284
+ // unit-testable DB-free. The title mirrors migration 160's insert exactly (a spaced
1285
+ // em-dash); it is built by ../goal-advisory.generalGoalTitle so this SQL and the
1286
+ // advisory that detects the same goal can never disagree (task 1002539).
1184
1287
  async function generalGoalIdForVersion(versionId, deps = {}) {
1185
1288
  if (versionId == null) return null;
1186
1289
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
1187
1290
  const { rows } = await activePool.query(
1188
- `SELECT id FROM goals WHERE version_id = $1 AND title = $1 || ' — general' LIMIT 1`,
1189
- [versionId]
1291
+ 'SELECT id FROM goals WHERE version_id = $1 AND title = $2 LIMIT 1',
1292
+ [versionId, generalGoalTitle(versionId)]
1190
1293
  );
1191
1294
  return rows[0] ? rows[0].id : null;
1192
1295
  }
@@ -1575,22 +1678,28 @@ async function updateTaskModuleKey(id, moduleKey) { return updateTaskColumn(id,
1575
1678
 
1576
1679
  // task 1957: credits_awarded is the ACTUAL drachmae paid out for completing a
1577
1680
  // task — the sum of the TASK-REWARD credit_log deltas booked against it: the
1578
- // confirm credit ('task.confirmed', = credits_reward × kind-multiplier), the
1579
- // idea-promotion bonus, and the net-negative (simplification) bonus, across all
1580
- // builders. The reason allowlist is deliberate: achievement unlocks
1581
- // ('achievement.*') also carry the shipped task's id but are GAMIFICATION, not a
1582
- // reward for the task, so they must NOT inflate the payout (session.token_reward
1583
- // and rank_change carry task_id=NULL and are excluded already). The CASE guard
1584
- // short-circuits to 0 for unsettled tasks so the hot board/claimable list never
1585
- // scans credit_log (credits don't land until 'confirmed' anyway) — the UI shows
1586
- // credits_reward (the creation-time ESTIMATE) with an "Est." label until then,
1587
- // and this real ledger figure once confirmed/shipped. Read-only; no schema change.
1681
+ // per-task credit (= credits_reward × kind-multiplier), the idea-promotion
1682
+ // bonus, and the net-negative (simplification) bonus, across all builders. The
1683
+ // reason allowlist is deliberate: achievement unlocks ('achievement.*') also
1684
+ // carry the shipped task's id but are GAMIFICATION, not a reward for the task,
1685
+ // so they must NOT inflate the payout (session.token_reward and rank_change
1686
+ // carry task_id=NULL and are excluded already).
1687
+ //
1688
+ // ADR 0120 "pay on land": the per-task credit reason is now 'task.shipped'
1689
+ // (booked at the confirmed→shipped flip) instead of 'task.confirmed' (booked at
1690
+ // confirm). BOTH strings are allow-listed: historical tasks confirmed under the
1691
+ // old rule carry 'task.confirmed' rows, new landings carry 'task.shipped', and
1692
+ // the one-time clawback reverses the old 'task.confirmed' deltas (a negative
1693
+ // row nets them to 0) — so summing both is correct across the transition. The
1694
+ // CASE still short-circuits to 0 for unsettled tasks; note a 'confirmed' task
1695
+ // now shows 0 until it LANDS (the credit no longer exists at confirm) — the UI
1696
+ // renders the "Est." label until the 'task.shipped' row books at land.
1588
1697
  const CREDITS_AWARDED_SUBQUERY =
1589
1698
  `CASE WHEN t.status IN ('confirmed', 'shipped')
1590
1699
  THEN (SELECT COALESCE(SUM(cl.delta), 0)::int
1591
1700
  FROM credit_log cl
1592
1701
  WHERE cl.task_id = t.id
1593
- AND cl.reason IN ('task.confirmed', 'idea_promotion_bonus', 'ship.net_negative'))
1702
+ AND cl.reason IN ('task.confirmed', 'task.shipped', 'idea_promotion_bonus', 'ship.net_negative'))
1594
1703
  ELSE 0 END`;
1595
1704
 
1596
1705
  async function getTask(id) {
@@ -1975,6 +2084,54 @@ async function getTaskDependents(taskId) {
1975
2084
  return getTaskDepEdges(taskId, { reverse: true });
1976
2085
  }
1977
2086
 
2087
+ // attachTaskDepSummaries (hall redesign task 1002439) — enrich an already-fetched
2088
+ // task list with the two dependency numbers the Goals rollup + Tasks dependency
2089
+ // view need, WITHOUT an N+1 (two aggregate queries over the whole id set, not one
2090
+ // per task). For each task:
2091
+ // blocked_by[] — the tasks THIS task waits on ({id,title,status,goal_id,satisfied}),
2092
+ // where satisfied === (target.status === 'shipped'), matching the
2093
+ // claim gate's DEPS_NOT_SHIPPED rule (ADR 0015). A `backlog` task
2094
+ // with any unsatisfied entry is what the UI paints "blocked".
2095
+ // blocks — how many tasks are waiting on THIS one (reverse edge count) —
2096
+ // the "clearing this unblocks N" signal.
2097
+ // task->task edges only (from_kind/to_kind='task'), same filter as getTaskDepEdges.
2098
+ // Mutates + returns the passed array so callers can `await db.attachTaskDepSummaries(tasks)`.
2099
+ async function attachTaskDepSummaries(tasks) {
2100
+ if (!Array.isArray(tasks) || tasks.length === 0) return tasks;
2101
+ const ids = tasks.map((t) => Number(t.id)).filter(Number.isFinite);
2102
+ if (ids.length === 0) return tasks;
2103
+ const [fwd, rev] = await Promise.all([
2104
+ pool.query(
2105
+ `SELECT d.from_id AS task_id,
2106
+ json_agg(json_build_object(
2107
+ 'id', t2.id, 'title', t2.title, 'status', t2.status,
2108
+ 'goal_id', t2.goal_id, 'satisfied', (t2.status = 'shipped')
2109
+ ) ORDER BY t2.id) AS blocked_by
2110
+ FROM dependencies d
2111
+ JOIN tasks t2 ON t2.id = d.to_id
2112
+ WHERE d.from_kind = 'task' AND d.to_kind = 'task'
2113
+ AND d.from_id = ANY($1::bigint[])
2114
+ GROUP BY d.from_id`,
2115
+ [ids]
2116
+ ),
2117
+ pool.query(
2118
+ `SELECT d.to_id AS task_id, count(*)::int AS blocks
2119
+ FROM dependencies d
2120
+ WHERE d.from_kind = 'task' AND d.to_kind = 'task'
2121
+ AND d.to_id = ANY($1::bigint[])
2122
+ GROUP BY d.to_id`,
2123
+ [ids]
2124
+ ),
2125
+ ]);
2126
+ const blockedBy = new Map(fwd.rows.map((r) => [String(r.task_id), r.blocked_by]));
2127
+ const blocks = new Map(rev.rows.map((r) => [String(r.task_id), r.blocks]));
2128
+ for (const t of tasks) {
2129
+ t.blocked_by = blockedBy.get(String(t.id)) || [];
2130
+ t.blocks = blocks.get(String(t.id)) || 0;
2131
+ }
2132
+ return tasks;
2133
+ }
2134
+
1978
2135
  // Generic cycle check over the polymorphic graph. Adding edge (from -> to) creates
1979
2136
  // a cycle iff `from` is already reachable FROM `to` along existing edges, so we seed
1980
2137
  // the walk at `to` and look for `from`. Nodes are (kind, id) pairs tokenized as
@@ -2323,6 +2480,7 @@ async function unsatisfiedDeps(taskId, client = pool) {
2323
2480
  // give a useful error rather than a generic constraint failure).
2324
2481
 
2325
2482
  async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
2483
+ const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
2326
2484
  const client = await pool.connect();
2327
2485
  try {
2328
2486
  await client.query('BEGIN');
@@ -2339,6 +2497,23 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2339
2497
  }
2340
2498
  const builderRank = builderRows[0] ? builderRows[0].rank : null;
2341
2499
 
2500
+ // ADR 0120 part 3: the needs_rebase claim gate. A builder who owes a rebase
2501
+ // on any un-landed confirmed task (flagged by the reconciler, part 2) is
2502
+ // blocked from taking on NEW work until they LAND it (rebase the branch +
2503
+ // re-ship → reconciler) or RELEASE it — both clear the flag. Their
2504
+ // already-active claims are untouched (we only refuse fresh claims, never
2505
+ // freeze in-flight work). Since the reward now lands only on ship (part 1),
2506
+ // the builder is motivated to clear it; since it gates their queue, they
2507
+ // can't walk away from it. Runs in THIS txn so it can't race a concurrent
2508
+ // ship that clears the last flag.
2509
+ const owedRebases = await needsRebaseTasksForBuilder(builderId, client);
2510
+ if (owedRebases.length > 0) {
2511
+ throw {
2512
+ code: 'REBASE_REQUIRED',
2513
+ rebaseTasks: owedRebases.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
2514
+ };
2515
+ }
2516
+
2342
2517
  const { rows: taskRows } = await client.query(
2343
2518
  `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind FROM tasks WHERE id = $1 FOR UPDATE`,
2344
2519
  [taskId]
@@ -2358,8 +2533,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2358
2533
  // only explicitly-tagged-open tasks" — markdown describes it, this check
2359
2534
  // enforces it. Error code is rank_too_low_for_task (V3.R15); XENOS_RESTRICTED
2360
2535
  // is kept as an alias on the error object for callers on the pre-rename
2361
- // release.
2362
- if (!xenosClaimAllowed(builderRank, task.newcomer_friendly)) {
2536
+ // release. BV1.R106 (ADR 0151): same rule, now decided on the PERMISSIONS
2537
+ // task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
2538
+ const newcomerFriendly = task.newcomer_friendly;
2539
+ if (!claimEligibilityAllows({ heldPermissions, newcomerFriendly, rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly) })) {
2363
2540
  throw { code: 'rank_too_low_for_task', legacy_code: 'XENOS_RESTRICTED' };
2364
2541
  }
2365
2542
 
@@ -2577,6 +2754,8 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
2577
2754
  // sharing any rows can't deadlock by locking in opposite orders.
2578
2755
  uniqIds.sort((a, b) => a - b);
2579
2756
 
2757
+ // BV1.R106: resolved BEFORE the tx opens — see claimPermissionsFor.
2758
+ const heldPermissions = await claimPermissionsFor(builderId);
2580
2759
  const client = await pool.connect();
2581
2760
  try {
2582
2761
  await client.query('BEGIN');
@@ -2620,9 +2799,20 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
2620
2799
  // Mirrors the single claimTask gate so the batch path can't be used to
2621
2800
  // sidestep the sandbox. Failure reason carries both names — the new
2622
2801
  // canonical code and the legacy alias — so callers on either release work.
2802
+ //
2803
+ // BV1.R106 (ADR 0151): the inner decision now reads the PERMISSION atoms, as
2804
+ // in claimTask. The outer `builderRank === 'xenos'` short-circuit is LEFT AS
2805
+ // IS: it is not a fast path but a semantic one (a reserved/unwired rank is
2806
+ // never checked here, while claimTask checks every rank), so removing it would
2807
+ // change who can batch-claim. That pre-existing asymmetry is reported, not
2808
+ // fixed, under this task — closing it is an authority decision of its own.
2623
2809
  if (builderRank === 'xenos') {
2624
2810
  for (const t of taskRows) {
2625
- if (!xenosClaimAllowed(builderRank, t.newcomer_friendly)) {
2811
+ if (!claimEligibilityAllows({
2812
+ heldPermissions,
2813
+ newcomerFriendly: t.newcomer_friendly,
2814
+ rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
2815
+ })) {
2626
2816
  failures.push({ task_id: t.id, reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
2627
2817
  }
2628
2818
  }
@@ -2858,7 +3048,13 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, forc
2858
3048
  [claimId]
2859
3049
  );
2860
3050
  await client.query(
2861
- `UPDATE tasks SET status = 'ready', updated_at = now() WHERE id = $1`,
3051
+ // ADR 0120 part 2: releasing a task frees it from the builder's queue,
3052
+ // so clear any needs_rebase flag — the rebase is no longer their chore
3053
+ // (this is the "release to unblock" exit the ADR guarantees).
3054
+ `UPDATE tasks
3055
+ SET status = 'ready', needs_rebase = false, needs_rebase_reason = NULL,
3056
+ needs_rebase_at = NULL, updated_at = now()
3057
+ WHERE id = $1`,
2862
3058
  [claim.task_id]
2863
3059
  );
2864
3060
  await sealSessionLog(client, {
@@ -2923,12 +3119,21 @@ async function validateClaimBatch({ taskIds, builderId }) {
2923
3119
  const reasonsById = new Map(uniqIds.map((id) => [id, []]));
2924
3120
  const push = (id, reason) => reasonsById.get(id).push(reason);
2925
3121
 
3122
+ // BV1.R106: same permission-expressed queue gate as claimTasksBatch (including
3123
+ // its `builderRank === 'xenos'` short-circuit), resolved once for the whole set
3124
+ // — authority is per-request, not per-row.
3125
+ const heldPermissions = await claimPermissionsFor(builderId);
3126
+
2926
3127
  for (const id of uniqIds) {
2927
3128
  if (builderInactive) push(id, { reason: 'BUILDER_INACTIVE' });
2928
3129
  const t = byId.get(id);
2929
3130
  if (!t) { push(id, { reason: 'TASK_NOT_FOUND' }); continue; }
2930
3131
  if (t.status !== 'ready') push(id, { reason: 'TASK_NOT_READY', currentStatus: t.status });
2931
- if (builderRank === 'xenos' && !xenosClaimAllowed(builderRank, t.newcomer_friendly)) {
3132
+ if (builderRank === 'xenos' && !claimEligibilityAllows({
3133
+ heldPermissions,
3134
+ newcomerFriendly: t.newcomer_friendly,
3135
+ rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
3136
+ })) {
2932
3137
  push(id, { reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
2933
3138
  }
2934
3139
  if (!rankAllowsTask(builderRank, t.requires_rank)) {
@@ -3002,7 +3207,8 @@ async function validateClaimBatch({ taskIds, builderId }) {
3002
3207
  // Resolve a claim. Three-state task lifecycle:
3003
3208
  // active → completed (builder declared done; awaiting grader)
3004
3209
  // active → completed → confirmed (promoted by applyGrade when the subagent
3005
- // grader passes; credits land at confirmed)
3210
+ // grader passes; verified but NOT yet paid —
3211
+ // ADR 0120 books credits at ship, not confirm)
3006
3212
  // active → ready (abandoned or partial — back to the queue)
3007
3213
  //
3008
3214
  // Caller passes `verification` = { passed: bool, scanner_clean, smoke_clean,
@@ -3168,8 +3374,9 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
3168
3374
  }
3169
3375
 
3170
3376
  // Final transition: confirmed → shipped. Called by /merge-mode (or auto-merge
3171
- // in /builder-ship) once the branch is on main + deployed. No credit
3172
- // implication — credits already landed at confirmed. Sets shipped_at.
3377
+ // in /builder-ship) once the branch is on main + deployed. THIS is the credit
3378
+ // moment — ADR 0120 pays on land, so awardLandingCredit books the reward here
3379
+ // (credit_log.reason = 'task.shipped'), not at confirm. Sets shipped_at.
3173
3380
  //
3174
3381
  // V3.R14 (#238): also stamps builders.first_ship_at the first time this
3175
3382
  // builder ever crosses the ship threshold. COALESCE makes the write
@@ -3177,20 +3384,104 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
3177
3384
  // the same transaction so a roster query that joins both tables sees a
3178
3385
  // consistent view.
3179
3386
 
3387
+ // awardLandingCredit — the per-task reward, booked at the SHIPPED transition
3388
+ // (ADR 0120 "pay on land"). Moved out of the three confirm paths (confirmTask /
3389
+ // decideOverrideRequest / applyGrade): the per-task credit + idea-promotion
3390
+ // bonus now exist ONLY once the code is merged to `main`, so a task that
3391
+ // strands at 'confirmed' pays nothing. Idempotent on (task_id,
3392
+ // reason='task.shipped') — mirrors the old (task_id, 'task.confirmed') guard —
3393
+ // so a re-ship or a reconciler re-sweep is a true no-op. The reason is
3394
+ // deliberately 'task.shipped' (not 'task.confirmed'): after the one-time
3395
+ // clawback (ADR 0120 part 4) reverses a stranded task's OLD 'task.confirmed'
3396
+ // row, re-landing books a FRESH 'task.shipped' row — the reward is fully
3397
+ // re-earnable, exactly as the ADR promises. Caller passes its ship txn client
3398
+ // so the credit commits atomically with the status flip.
3399
+ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind }) {
3400
+ const reward = seams.resolveOptional('reward');
3401
+ if (!reward || creditedBuilderId == null || !(Number(creditsReward) > 0)) {
3402
+ return { creditsAwarded: 0, ideaBonus: null };
3403
+ }
3404
+ const { rows: existing } = await client.query(
3405
+ `SELECT 1 FROM credit_log WHERE task_id = $1 AND reason = 'task.shipped' LIMIT 1`,
3406
+ [taskId]
3407
+ );
3408
+ if (existing.length > 0) return { creditsAwarded: 0, ideaBonus: null };
3409
+ const credit = await reward.insertMultipliedConfirmCredit(
3410
+ client, creditedBuilderId, taskId, creditsReward, kind, 'task.shipped'
3411
+ );
3412
+ const ideaBonus = await reward.maybeAwardIdeaPromotionBonus(
3413
+ client, taskId, creditedBuilderId, credit.creditsAwarded
3414
+ );
3415
+ return {
3416
+ creditsAwarded: credit.creditsAwarded,
3417
+ creditsRaw: credit.raw,
3418
+ creditsMultiplier: credit.multiplier,
3419
+ ideaBonus,
3420
+ };
3421
+ }
3422
+
3423
+ // flagTaskNeedsRebase — ADR 0120 part 2. The publish-reconciler calls this when
3424
+ // a confirmed task's land cannot be completed after the auto-land grace (its
3425
+ // retries + the ADR 0082 generated-file self-heal): a persistently-red PR that
3426
+ // hit the regen-heal cap, or a strand older than the stale threshold that can't
3427
+ // be proven landed (never pushed / no recorded tip). It is a SIGNAL, not a
3428
+ // status change — the task stays 'confirmed'; it is simply marked as awaiting
3429
+ // its BUILDER's rebase (attribution via tasks.shipped_by = claim holder).
3430
+ //
3431
+ // Idempotent + self-limiting: only a still-'confirmed', not-already-flagged task
3432
+ // is touched (WHERE status='confirmed' AND needs_rebase=false), so a task that
3433
+ // has since shipped or been released is never (re-)flagged, and a re-sweep of an
3434
+ // already-flagged task is a no-op. Returns true iff this call newly flagged it
3435
+ // (so the reconciler logs the transition once). needs_rebase_at is set once
3436
+ // (COALESCE) so the hall/CLI can show how long it has been outstanding.
3437
+ async function flagTaskNeedsRebase(taskId, reason = null) {
3438
+ const { rows } = await pool.query(
3439
+ `UPDATE tasks
3440
+ SET needs_rebase = true,
3441
+ needs_rebase_reason = $2,
3442
+ needs_rebase_at = COALESCE(needs_rebase_at, now()),
3443
+ updated_at = now()
3444
+ WHERE id = $1 AND status = 'confirmed' AND needs_rebase = false
3445
+ RETURNING id`,
3446
+ [taskId, reason]
3447
+ );
3448
+ return rows.length > 0;
3449
+ }
3450
+
3451
+ // needsRebaseTasksForBuilder — the confirmed tasks a builder owes a rebase on
3452
+ // (ADR 0120 parts 2-3). Attribution is tasks.shipped_by (= the claim holder,
3453
+ // COALESCE'd there at confirm). Used by the claim gate (part 3) to refuse NEW
3454
+ // claims and by the hall/CLI surfacing (part 5). Accepts an optional client so
3455
+ // it can run inside claimTask's FOR UPDATE txn or standalone on the pool.
3456
+ // Ordered oldest-flagged first so the surfacing can show the longest-outstanding.
3457
+ async function needsRebaseTasksForBuilder(builderId, client = pool) {
3458
+ const { rows } = await client.query(
3459
+ `SELECT id, title, needs_rebase_reason, needs_rebase_at
3460
+ FROM tasks
3461
+ WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
3462
+ ORDER BY needs_rebase_at ASC NULLS LAST, id ASC`,
3463
+ [builderId]
3464
+ );
3465
+ return rows;
3466
+ }
3467
+
3180
3468
  async function shipTask({ taskId, builderId }, deps = {}) {
3181
3469
  // #538 test seam (mirrors confirmTask): a caller-supplied pool is honored ONLY
3182
3470
  // under NODE_ENV==='test', so a regression test can drive the txn with a
3183
3471
  // SQL-aware fakeClient. Production callers omit deps → module pool.
3184
3472
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
3185
- const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount } = await withTx(async (client) => {
3473
+ const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked } = await withTx(async (client) => {
3186
3474
  const { rows } = await client.query(
3187
3475
  `UPDATE tasks
3188
3476
  SET status = 'shipped',
3189
3477
  shipped_at = now(),
3190
3478
  shipped_by = COALESCE(shipped_by, $2),
3479
+ needs_rebase = false,
3480
+ needs_rebase_reason = NULL,
3481
+ needs_rebase_at = NULL,
3191
3482
  updated_at = now()
3192
3483
  WHERE id = $1 AND status = 'confirmed'
3193
- RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary`,
3484
+ RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind`,
3194
3485
  [taskId, builderId]
3195
3486
  );
3196
3487
  if (rows.length === 0) {
@@ -3277,7 +3568,47 @@ async function shipTask({ taskId, builderId }, deps = {}) {
3277
3568
  // xenos→thetes atomically with this ship. Rank-guarded + idempotent.
3278
3569
  const rankGraduation = await maybeAutoGraduateToThetes(client, actualShipperId);
3279
3570
 
3280
- return { shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId, closedClaimCount: closedClaims.length };
3571
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
3572
+ // booked HERE, at the confirmed→shipped flip — no longer at confirm. The
3573
+ // credit only exists once the code is on `main`; a task that strands at
3574
+ // 'confirmed' (never lands) now pays nothing. Attribution is the CLAIM
3575
+ // HOLDER (the builder who did the work), computed the same way as
3576
+ // confirmTask — never the caller who happened to trigger the ship (the
3577
+ // reconciler / a supervisor). mostRecentClaimHolderId reads the latest
3578
+ // claim by claimed_at regardless of released_at, so it is correct even
3579
+ // though the claims for this task were just closed above. Idempotent on
3580
+ // (task_id, reason='task.shipped'), so a re-ship / reconciler re-sweep is a
3581
+ // true no-op. Runs in THIS txn so the credit commits atomically with the
3582
+ // status flip.
3583
+ const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? actualShipperId;
3584
+ const landing = await awardLandingCredit(client, {
3585
+ taskId,
3586
+ creditedBuilderId,
3587
+ creditsReward: rows[0].credits_reward,
3588
+ kind: rows[0].kind,
3589
+ });
3590
+ // task 1679 pattern: state-based achievement reconcile now that the credit
3591
+ // (above) + the ship count are reflected in this txn. SAVEPOINT-wrapped so
3592
+ // a gamification error can never fail the ship. Moved to ship because the
3593
+ // credit it keys off (breaking-the-100c-bar etc.) now lands here.
3594
+ let justUnlocked = [];
3595
+ const rewardPort = seams.resolveOptional('reward');
3596
+ if (rewardPort && rewardPort.reconcileBuilderAchievements && creditedBuilderId != null) {
3597
+ await client.query('SAVEPOINT ach_reconcile_ship');
3598
+ try {
3599
+ justUnlocked = await rewardPort.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
3600
+ await client.query('RELEASE SAVEPOINT ach_reconcile_ship');
3601
+ } catch (_) {
3602
+ await client.query('ROLLBACK TO SAVEPOINT ach_reconcile_ship');
3603
+ justUnlocked = [];
3604
+ }
3605
+ }
3606
+
3607
+ return {
3608
+ shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
3609
+ closedClaimCount: closedClaims.length,
3610
+ creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, justUnlocked,
3611
+ };
3281
3612
  }, { pool: activePool });
3282
3613
  if (closedClaimCount > 0) {
3283
3614
  // Post-commit observability: a normal ship closes 0 here (resolveClaim
@@ -3297,7 +3628,14 @@ async function shipTask({ taskId, builderId }, deps = {}) {
3297
3628
  builderId: actualShipperId, rank: 'thetes', status: rankGraduation.status, actor: 'auto-graduation:#692',
3298
3629
  }).catch(() => {});
3299
3630
  }
3300
- return { ...shipRow, graduatedTo: rankGraduation ? 'thetes' : null };
3631
+ return {
3632
+ ...shipRow,
3633
+ graduatedTo: rankGraduation ? 'thetes' : null,
3634
+ // ADR 0120: the per-task reward that landed on THIS ship (0 on a re-ship /
3635
+ // reconciler re-sweep — idempotent). The ship route surfaces it so the
3636
+ // builder sees "credits landed" at ship, not at confirm.
3637
+ creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked,
3638
+ };
3301
3639
  }
3302
3640
 
3303
3641
  // task 1134 — feed for the server-owned publish reconciler (src/bongos/
@@ -3544,6 +3882,9 @@ async function releaseClaimOnBehalf({ claimId, archonId, archonLogin, reason })
3544
3882
  await client.query(
3545
3883
  `UPDATE tasks
3546
3884
  SET status = 'ready',
3885
+ needs_rebase = false,
3886
+ needs_rebase_reason = NULL,
3887
+ needs_rebase_at = NULL,
3547
3888
  updated_at = now()
3548
3889
  WHERE id = $1`,
3549
3890
  [taskId]
@@ -3610,25 +3951,16 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
3610
3951
  // without economy, so every use is guarded (degrades to a best-effort no-op).
3611
3952
  const reward = seams.resolveOptional('reward');
3612
3953
 
3613
- // Award credits if not already in credit_log for this task. V3.R27 (#247)
3614
- // routes the insert through insertMultipliedConfirmCredit so the kind
3615
- // multiplier and self-describing description apply to manual-confirms
3616
- // identically to grader-confirms.
3617
- const { rows: existing } = await client.query(
3618
- `SELECT 1 FROM credit_log WHERE task_id = $1 AND reason = 'task.confirmed' LIMIT 1`,
3619
- [taskId]
3620
- );
3621
- let creditsAwarded = 0;
3622
- let ideaBonus = null;
3623
- if (reward && existing.length === 0 && task.credits_reward > 0) {
3624
- const credit = await reward.insertMultipliedConfirmCredit(
3625
- client, creditedBuilderId, taskId, task.credits_reward, task.kind
3626
- );
3627
- creditsAwarded = credit.creditsAwarded;
3628
- ideaBonus = await reward.maybeAwardIdeaPromotionBonus(
3629
- client, taskId, creditedBuilderId, creditsAwarded
3630
- );
3631
- }
3954
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are NO
3955
+ // LONGER awarded at confirm. They land at the confirmed→shipped flip
3956
+ // (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task that
3957
+ // strands at 'confirmed' pays nothing until its code is on `main`. confirm
3958
+ // still flips the status and attributes shipped_by to the claim holder
3959
+ // (above); the reward waits for the land. creditsAwarded/ideaBonus stay in
3960
+ // the return shape (0 / null at confirm) so callers/tests keep a stable
3961
+ // contract.
3962
+ const creditsAwarded = 0;
3963
+ const ideaBonus = null;
3632
3964
 
3633
3965
  // task 1679: confirmTask is the --skip-grade / grader_bypass ship path — the
3634
3966
  // DOMINANT one — and until now it awarded credits but NEVER evaluated
@@ -3679,8 +4011,9 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
3679
4011
  // still owns the work; release it first so the abandon
3680
4012
  // can never race a ship in flight.
3681
4013
  // Any other status (backlog/ready/blocked/completed/confirmed) abandons. The
3682
- // route is archon-gated, and the abandon + its reason are captured in audit_log
3683
- // by the writing middleware, so the append-only ledger records who retired what.
4014
+ // route is metic+archon-gated (ADR 0152; archon-only until then), and the abandon
4015
+ // + its reason are captured in audit_log by the writing middleware, so the
4016
+ // append-only ledger records who retired what. restoreTask (below) is the undo.
3684
4017
  //
3685
4018
  // NOTE on dependencies: abandoning a task that is listed in another task's
3686
4019
  // dependencies[] does NOT auto-adjust those dependents — they will simply never
@@ -3749,6 +4082,69 @@ async function abandonTask({ taskId, reason }, deps = {}) {
3749
4082
  }
3750
4083
  }
3751
4084
 
4085
+ // restoreTask (ADR 0152) — the inverse of abandonTask: 'abandoned' → 'backlog'.
4086
+ // Added when abandon was re-ranked Archon→Metic+, because until then abandon was
4087
+ // one-way through the API (promote took only backlog/blocked, PATCH refuses
4088
+ // `status`), so a mis-retire could only be undone with hand-SQL on prod. Handing a
4089
+ // Metic the kill switch without an undo at the same rank would have moved the
4090
+ // owner-cleanup burden rather than removed it.
4091
+ //
4092
+ // Restores to **backlog**, not ready: the task was retired deliberately, so it
4093
+ // comes back un-workable and needs a second, explicit promote to be claimable.
4094
+ // abandonTask stamps `value_summary = 'Abandoned: <reason>'`; a restored task
4095
+ // clears exactly that stamp (and nothing else) so a live task never carries a
4096
+ // retire note into its ship card / broadcast.
4097
+ //
4098
+ // Guards: TASK_NOT_FOUND, and TASK_NOT_ABANDONED for any other status (the route
4099
+ // checks first, but the db guard makes a direct caller — a CLI, a poller — safe
4100
+ // too). Reached via POST /tasks/:id/promote; the audit_log row is written by the
4101
+ // writing middleware there, same as the abandon it reverses.
4102
+ //
4103
+ // #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
4104
+ // (matches abandonTask/confirmTask). Production callers omit deps → module pool.
4105
+ async function restoreTask({ taskId }, deps = {}) {
4106
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
4107
+ const client = await activePool.connect();
4108
+ try {
4109
+ await client.query('BEGIN');
4110
+
4111
+ const { rows: taskRows } = await client.query(
4112
+ `SELECT id, title, status FROM tasks WHERE id = $1 FOR UPDATE`,
4113
+ [taskId]
4114
+ );
4115
+ if (taskRows.length === 0) {
4116
+ throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
4117
+ }
4118
+ const task = taskRows[0];
4119
+ if (task.status !== 'abandoned') {
4120
+ throw Object.assign(new Error('task is not abandoned'), { code: 'TASK_NOT_ABANDONED' });
4121
+ }
4122
+
4123
+ const { rows: updated } = await client.query(
4124
+ `UPDATE tasks
4125
+ SET status = 'backlog',
4126
+ value_summary = CASE WHEN value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END,
4127
+ updated_at = now()
4128
+ WHERE id = $1
4129
+ RETURNING id, title, status`,
4130
+ [taskId]
4131
+ );
4132
+
4133
+ await client.query('COMMIT');
4134
+ return {
4135
+ taskId,
4136
+ taskTitle: updated[0].title,
4137
+ taskStatus: 'backlog',
4138
+ previousStatus: 'abandoned',
4139
+ };
4140
+ } catch (err) {
4141
+ await client.query('ROLLBACK').catch(() => {});
4142
+ throw err;
4143
+ } finally {
4144
+ client.release();
4145
+ }
4146
+ }
4147
+
3752
4148
  // task override requests — V3.R74 (#293). Rare path: a builder whose ship was
3753
4149
  // rejected by the subagent grader (task stays at 'completed', no credits)
3754
4150
  // believes the grader was wrong and asks an Archon to confirm anyway.
@@ -3886,31 +4282,14 @@ async function decideOverrideRequest({ requestId, archonId, decision, note }, de
3886
4282
  // can always destructure it from confirmResult without a guard (#538).
3887
4283
  confirmResult = { taskFlipped: false, creditsAwarded: 0, ideaBonus: null, creditedBuilderId };
3888
4284
  } else {
3889
- const task = taskRows[0];
3890
- const { rows: existing } = await client.query(
3891
- `SELECT 1 FROM credit_log WHERE task_id = $1 AND reason = 'task.confirmed' LIMIT 1`,
3892
- [task.id]
3893
- );
3894
- let creditsAwarded = 0;
3895
- let ideaBonus = null;
3896
- if (existing.length === 0 && task.credits_reward > 0) {
3897
- // V3.R27 (#247): apply kind multiplier on the override-confirm path
3898
- // too, so a builder who had to argue for their ship is not punished
3899
- // by getting the legacy 1.0× payout. Credit allocation carved to
3900
- // modules/economy/ (BV1.R77 / ADR 0093 §1-2): resolve the `reward`
3901
- // kernel port and write the credit inside this same txn.
3902
- const reward = seams.resolveOptional('reward');
3903
- const credit = await reward.insertMultipliedConfirmCredit(
3904
- client, creditedBuilderId, task.id, task.credits_reward, task.kind
3905
- );
3906
- creditsAwarded = credit.creditsAwarded;
3907
- ideaBonus = await reward.maybeAwardIdeaPromotionBonus(
3908
- client, task.id, creditedBuilderId, creditsAwarded
3909
- );
3910
- }
3911
- // #704: thetes graduation is evaluated at SHIP time (shipTask), not on
3912
- // confirm/override — credits here, rank graduation when the task ships.
3913
- confirmResult = { taskFlipped: true, creditsAwarded, ideaBonus, creditedBuilderId };
4285
+ // ADR 0120 "pay on land": the override-approval path flips the task
4286
+ // completed→confirmed and attributes shipped_by to the claim holder
4287
+ // (above), but the per-task credit + idea bonus now wait for the land
4288
+ // (shipTask → awardLandingCredit, keyed 'task.shipped'). A stranded
4289
+ // override-confirmed task pays nothing until its code is on `main`.
4290
+ // creditsAwarded/ideaBonus stay in the return shape (0 / null) for a
4291
+ // stable contract; thetes graduation was already ship-time (#704).
4292
+ confirmResult = { taskFlipped: true, creditsAwarded: 0, ideaBonus: null, creditedBuilderId };
3914
4293
  }
3915
4294
  }
3916
4295
 
@@ -4068,57 +4447,20 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
4068
4447
  netNegativeBonus = nn.bonus;
4069
4448
  subtractorUnlocks = nn.justUnlocked;
4070
4449
 
4071
- // Credit award is idempotent on (task_id, 'task.confirmed'). A second
4072
- // pass through applyGrade for the same task (re-grade scenario) is a
4073
- // no-op for credit_log.
4074
- const { rows: existing } = await client.query(
4075
- `SELECT 1 FROM credit_log WHERE task_id = $1 AND reason = 'task.confirmed' LIMIT 1`,
4076
- [taskId]
4077
- );
4078
- if (existing.length === 0 && task.credits_reward > 0) {
4079
- const { rows: beforeRows } = await client.query(
4080
- `SELECT total_credits FROM builders WHERE id = $1`,
4081
- [creditedBuilderId]
4082
- );
4083
- const totalBefore = Number(beforeRows[0]?.total_credits ?? 0);
4084
-
4085
- // V3.R27 (#247): apply the per-kind multiplier. The credit_log
4086
- // description records "<raw>c × <mult> = <effective>c (<kind>)" so
4087
- // the audit trail is self-explaining; the trigger on credit_log rolls
4088
- // the effective value into builders.total_credits exactly as before.
4089
- const credit = await reward.insertMultipliedConfirmCredit(
4090
- client, creditedBuilderId, taskId, task.credits_reward, task.kind
4091
- );
4092
- creditsAwarded = credit.creditsAwarded;
4093
- creditsRaw = credit.raw;
4094
- creditsMultiplier = credit.multiplier;
4095
-
4096
- // V3.R27 (#247): staged idea-promotion bonus. If this task came out
4097
- // of idea_inbox and the capturer is a different builder than the
4098
- // shipper, give the capturer 25% of the multiplier-adjusted reward.
4099
- // Runs INSIDE the same txn so total_credits and bonus stay atomic.
4100
- ideaBonus = await reward.maybeAwardIdeaPromotionBonus(
4101
- client, taskId, creditedBuilderId, creditsAwarded
4102
- );
4103
-
4104
- // #704: thetes graduation is evaluated at SHIP time (shipTask), not at
4105
- // grader-confirm — credits land here, rank graduation when the task ships.
4106
-
4107
- const { rows: afterRows } = await client.query(
4108
- `SELECT total_credits FROM builders WHERE id = $1`,
4109
- [creditedBuilderId]
4110
- );
4111
- const totalAfter = Number(afterRows[0]?.total_credits ?? 0);
4112
-
4113
- const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
4114
- const creditUnlocks = await reward.evaluateAfterCreditAward(
4115
- client, creditedBuilderId, totalBefore, totalAfter
4116
- );
4117
- justUnlocked = [...subtractorUnlocks, ...shipUnlocks, ...creditUnlocks];
4118
- } else {
4119
- const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
4120
- justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
4121
- }
4450
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
4451
+ // NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
4452
+ // flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
4453
+ // that strands at 'confirmed' pays nothing until its code is on `main`.
4454
+ // The net-negative (simplification) bonus above STAYS here — it derives
4455
+ // from grade-time diff_stats that only exist at grade time, so it cannot
4456
+ // move to shipTask (and is outside ADR 0120's two named reward writes).
4457
+ // creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
4458
+ // confirm for a stable return contract; the credit lands at ship. The
4459
+ // credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
4460
+ // with the credit to shipTask's reconcile; here we still fire the
4461
+ // ship/subtractor unlocks that DON'T depend on the per-task credit.
4462
+ const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
4463
+ justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
4122
4464
 
4123
4465
  // task 1679: state-based safety net. The granular evaluators above are
4124
4466
  // boundary-/path-sensitive — breaking-the-100c-bar only fires when a
@@ -4193,19 +4535,52 @@ async function getActiveClaimsForBuilder(builderId) {
4193
4535
  return rows;
4194
4536
  }
4195
4537
 
4538
+ // Task-scoped dev box (goal 1000051 / R92, ADR 0148 §2). The set-union of the
4539
+ // module KEYS a builder's active claims put in scope: each active claim's task →
4540
+ // its goal → goal.scope_modules (the ADR 0086 scope wall), unioned + deduped
4541
+ // across all the builder's active claims. This is the "what code does this
4542
+ // builder's current work need" signal the dev-box source-access resolver
4543
+ // (modules/dev-box/box-access.js, wired in R93/R94) maps to git sparse-checkout
4544
+ // globs via the doorway `moduleScopeMap`. Returns [] when the builder holds no
4545
+ // active claim, or when every claimed task carries no goal / an empty
4546
+ // scope_modules — the box then falls back to BASE-only (the pre-existing Bongos
4547
+ // instance). KEYS only; glob resolution is the box module's job (it reaches
4548
+ // moduleScopeMap through the doorway, never a core-internal require — the
4549
+ // R90/ADR-0148 boundary). Honours the #538 deps.pool test seam
4550
+ // (NODE_ENV==='test' only), like the goals reads above.
4551
+ async function moduleScopeForActiveClaims(builderId, deps = {}) {
4552
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
4553
+ const id = Number(builderId);
4554
+ if (!Number.isInteger(id)) {
4555
+ throw new Error('moduleScopeForActiveClaims: builderId must be numeric');
4556
+ }
4557
+ const { rows } = await activePool.query(
4558
+ `SELECT DISTINCT m AS module_key
4559
+ FROM claims c
4560
+ JOIN tasks t ON t.id = c.task_id
4561
+ JOIN goals g ON g.id = t.goal_id
4562
+ CROSS JOIN LATERAL unnest(g.scope_modules) AS m
4563
+ WHERE c.builder_id = $1 AND c.released_at IS NULL
4564
+ ORDER BY m`,
4565
+ [id]
4566
+ );
4567
+ return rows.map((r) => r.module_key);
4568
+ }
4569
+
4196
4570
  // task 1642 — every active claim bound to a given CLI session. "Active" is the
4197
4571
  // claims-table convention (released_at IS NULL); there is NO `status` column on
4198
4572
  // claims (a common misread — the task's status lives on `tasks`). Keyed on
4199
- // creator_session_id (the worktree's session), NOT builder_id: a builder may run
4200
- // many concurrent sessions, each with exactly one claim/one worktree, and that
4201
- // norm must keep working. This backs the session-scoped one-active-claim guard
4202
- // (shouldRefuseSessionClaim) that blocks commingled ships. NULL-session callers
4203
- // must never reach this — they are session-UNBOUND web claims (bind_session=false)
4204
- // that any CLI session may adopt; the route short-circuits before calling here.
4573
+ // creator_session_id (the auth-token session shared across a builder's worktrees),
4574
+ // NOT builder_id. This backs the one-active-claim guard (shouldRefuseSessionClaim),
4575
+ // which further discriminates by `worktree_name` — the real commingle boundary —
4576
+ // so two claims can coexist for one auth session as long as they land in different
4577
+ // worktrees. NULL-session callers must never reach this — they are session-UNBOUND
4578
+ // web claims (bind_session=false) that any CLI session may adopt; the route
4579
+ // short-circuits before calling here.
4205
4580
  async function getActiveClaimsForSession(creatorSessionId) {
4206
4581
  if (!creatorSessionId) return [];
4207
4582
  const { rows } = await pool.query(
4208
- `SELECT c.id AS claim_id, c.task_id, c.creator_session_id, c.claimed_at,
4583
+ `SELECT c.id AS claim_id, c.task_id, c.creator_session_id, c.worktree_name, c.claimed_at,
4209
4584
  t.title
4210
4585
  FROM claims c JOIN tasks t ON t.id = c.task_id
4211
4586
  WHERE c.creator_session_id = $1 AND c.released_at IS NULL
@@ -4952,6 +5327,7 @@ module.exports = {
4952
5327
  updateTaskStatus,
4953
5328
  getTaskDependencies,
4954
5329
  getTaskDependents,
5330
+ attachTaskDepSummaries,
4955
5331
  addTaskDependency,
4956
5332
  removeTaskDependency,
4957
5333
  setTaskDependencies,
@@ -4974,12 +5350,16 @@ module.exports = {
4974
5350
  resolveClaim,
4975
5351
  confirmTask,
4976
5352
  abandonTask,
5353
+ restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
4977
5354
  applyGrade,
4978
5355
  shipTask,
5356
+ flagTaskNeedsRebase, // ADR 0120 part 2 — reconciler flags an unlandable confirmed task for its builder
5357
+ needsRebaseTasksForBuilder, // ADR 0120 part 3/5 — the claim gate + hall/CLI read the builder's owed rebases
4979
5358
  listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
4980
5359
  setClaimPublishedSha, // task 1547 — record the SERVER-pushed tip for per-task land proof
4981
5360
  getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
4982
5361
  getActiveClaimsForBuilder,
5362
+ moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
4983
5363
  getActiveClaimsForSession,
4984
5364
  getLastClaimHolder, // --regrade ownership guard (#678)
4985
5365
  getTaskGrade, // V4.R08 — diff-hash reuse check before grading