@bongos/core 1.17.0

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 (1142) hide show
  1. package/.bongos-core.json +5727 -0
  2. package/.claude/skills/blocker-review/SKILL.md +91 -0
  3. package/.claude/skills/blocker-solve/SKILL.md +96 -0
  4. package/.claude/skills/builder-backup/SKILL.md +54 -0
  5. package/.claude/skills/builder-box/SKILL.md +45 -0
  6. package/.claude/skills/builder-claim/SKILL.md +76 -0
  7. package/.claude/skills/builder-connect/SKILL.md +59 -0
  8. package/.claude/skills/builder-cost/SKILL.md +47 -0
  9. package/.claude/skills/builder-end/SKILL.md +81 -0
  10. package/.claude/skills/builder-exit/SKILL.md +88 -0
  11. package/.claude/skills/builder-key/SKILL.md +40 -0
  12. package/.claude/skills/builder-reauth/SKILL.md +50 -0
  13. package/.claude/skills/builder-redteam/SKILL.md +109 -0
  14. package/.claude/skills/builder-release/SKILL.md +41 -0
  15. package/.claude/skills/builder-setup/SKILL.md +62 -0
  16. package/.claude/skills/builder-ship/SKILL.md +79 -0
  17. package/.claude/skills/builder-stage/SKILL.md +51 -0
  18. package/.claude/skills/builder-start/SKILL.md +46 -0
  19. package/.claude/skills/close-box/SKILL.md +43 -0
  20. package/.claude/skills/design-sync/SKILL.md +69 -0
  21. package/.claude/skills/dev/SKILL.md +49 -0
  22. package/.claude/skills/feedback/SKILL.md +32 -0
  23. package/.claude/skills/figma-design-sync/SKILL.md +74 -0
  24. package/.claude/skills/goal-review/SKILL.md +89 -0
  25. package/.claude/skills/idea-triage/SKILL.md +144 -0
  26. package/.claude/skills/ideate/SKILL.md +82 -0
  27. package/.claude/skills/merge-mode/SKILL.md +169 -0
  28. package/.claude/skills/new-project/SKILL.md +108 -0
  29. package/.claude/skills/otb-character-review/SKILL.md +71 -0
  30. package/.claude/skills/otb-design-review/SKILL.md +47 -0
  31. package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
  32. package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
  33. package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
  34. package/.claude/skills/paint/SKILL.md +70 -0
  35. package/.claude/skills/planning-session/SKILL.md +299 -0
  36. package/.claude/skills/priority-session/SKILL.md +134 -0
  37. package/.claude/skills/read-session-export/SKILL.md +58 -0
  38. package/.claude/skills/recall/SKILL.md +52 -0
  39. package/.claude/skills/session-handoff/SKILL.md +46 -0
  40. package/.claude/skills/status/SKILL.md +33 -0
  41. package/.devcontainer/Dockerfile +69 -0
  42. package/.devcontainer/README.md +73 -0
  43. package/.devcontainer/devcontainer-lock.json +19 -0
  44. package/.devcontainer/devcontainer.json +93 -0
  45. package/.devcontainer/healthcheck.sh +84 -0
  46. package/.devcontainer/init-firewall.sh +198 -0
  47. package/.devcontainer/post-create.sh +153 -0
  48. package/.dockerignore +36 -0
  49. package/.env.local.example +32 -0
  50. package/.gitattributes +83 -0
  51. package/.gitignore +91 -0
  52. package/CLAUDE.md +242 -0
  53. package/CODE_OF_CONDUCT.md +46 -0
  54. package/CONTRIBUTING.md +51 -0
  55. package/Dockerfile +46 -0
  56. package/GOVERNANCE.md +44 -0
  57. package/LICENSE +661 -0
  58. package/README.md +88 -0
  59. package/bin/bongos.js +222 -0
  60. package/clients/bongos-client/README.md +48 -0
  61. package/clients/bongos-client/bongos-client.global.js +617 -0
  62. package/clients/bongos-client/examples/hello-world.mjs +62 -0
  63. package/clients/bongos-client/index.cjs +619 -0
  64. package/clients/bongos-client/index.d.ts +793 -0
  65. package/clients/bongos-client/index.mjs +614 -0
  66. package/clients/bongos-client/package.json +32 -0
  67. package/config/branding.neutral.json +60 -0
  68. package/config/design-tokens.neutral.json +51 -0
  69. package/config/design-tokens.schema.json +96 -0
  70. package/config/modules.neutral.json +9 -0
  71. package/docker-compose.yml +52 -0
  72. package/docker-entrypoint.sh +30 -0
  73. package/docs/adr/0001-stack-choice-single-vps.md +45 -0
  74. package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
  75. package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
  76. package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
  77. package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
  78. package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
  79. package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
  80. package/docs/adr/0008-family-based-tile-generation.md +86 -0
  81. package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
  82. package/docs/adr/0009-autotile-architecture.md +181 -0
  83. package/docs/adr/0010-pms-architecture.md +70 -0
  84. package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
  85. package/docs/adr/0012-ground-object-cell-split.md +208 -0
  86. package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
  87. package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
  88. package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
  89. package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
  90. package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
  91. package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
  92. package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
  93. package/docs/adr/0020-security-model-public-stub.md +70 -0
  94. package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
  95. package/docs/adr/0022-secrets-policy.md +124 -0
  96. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
  97. package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
  98. package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
  99. package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
  100. package/docs/adr/0025-structured-criterion-task-link.md +44 -0
  101. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
  102. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
  103. package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
  104. package/docs/adr/0029-status-page-outage-resilience.md +59 -0
  105. package/docs/adr/0030-builders-hall-live-updates.md +59 -0
  106. package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
  107. package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
  108. package/docs/adr/0033-discord-bot-service-principal.md +82 -0
  109. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
  110. package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
  111. package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
  112. package/docs/adr/0037-discord-channel-management.md +51 -0
  113. package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
  114. package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
  115. package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
  116. package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
  117. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
  118. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
  119. package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
  120. package/docs/adr/0044-per-box-live-game-preview.md +57 -0
  121. package/docs/adr/0045-devbox-desktop-app.md +133 -0
  122. package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
  123. package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
  124. package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
  125. package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
  126. package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
  127. package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
  128. package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
  129. package/docs/adr/0053-scoped-dev-box-session.md +55 -0
  130. package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
  131. package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
  132. package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
  133. package/docs/adr/0057-container-cost-ledger.md +28 -0
  134. package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
  135. package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
  136. package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
  137. package/docs/adr/0060-gds-retrieval-layer.md +97 -0
  138. package/docs/adr/0061-context-layer-decomposition.md +61 -0
  139. package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
  140. package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
  141. package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
  142. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
  143. package/docs/adr/0066-generated-file-map-sections.md +51 -0
  144. package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
  145. package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
  146. package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
  147. package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
  148. package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
  149. package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
  150. package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
  151. package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
  152. package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
  153. package/docs/adr/0074-memory-map-stays-light.md +32 -0
  154. package/docs/adr/0075-per-system-cost-attribution.md +37 -0
  155. package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
  156. package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
  157. package/docs/adr/0078-deterministic-first-cron.md +48 -0
  158. package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
  159. package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
  160. package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
  161. package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
  162. package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
  163. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
  164. package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
  165. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
  166. package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
  167. package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
  168. package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
  169. package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
  170. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
  171. package/docs/adr/0090-metic-task-authoring.md +73 -0
  172. package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
  173. package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
  174. package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
  175. package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
  176. package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
  177. package/docs/adr/0095-cross-agent-context-management.md +58 -0
  178. package/docs/adr/0096-require-reward-before-workable.md +209 -0
  179. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
  180. package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
  181. package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
  182. package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
  183. package/docs/adr/0100-two-repo-instance-model.md +60 -0
  184. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
  185. package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
  186. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
  187. package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
  188. package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
  189. package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
  190. package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
  191. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
  192. package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
  193. package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
  194. package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
  195. package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
  196. package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
  197. package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
  198. package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
  199. package/docs/adr/0115-scheduled-routines-portability.md +31 -0
  200. package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
  201. package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
  202. package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
  203. package/docs/adr/0119-api-pagination-contract.md +34 -0
  204. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
  205. package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
  206. package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
  207. package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
  208. package/docs/adr/0124-typed-api-response-bodies.md +37 -0
  209. package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
  210. package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
  211. package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
  212. package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
  213. package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
  214. package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
  215. package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
  216. package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
  217. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
  218. package/docs/adr/0134-private-first-npm-distribution.md +38 -0
  219. package/docs/adr/README.md +208 -0
  220. package/docs/api/openapi.json +13720 -0
  221. package/docs/api-reference.md +437 -0
  222. package/docs/architecture.md +244 -0
  223. package/docs/branding-contract.md +66 -0
  224. package/docs/branding-fill-prompt.md +25 -0
  225. package/docs/canonical-permissions.md +241 -0
  226. package/docs/design/container-exterior-tiles.md +49 -0
  227. package/docs/design/container-exterior.md +81 -0
  228. package/docs/design/container-interior.md +77 -0
  229. package/docs/design/modular-architecture/00-research-report.md +107 -0
  230. package/docs/design/modular-architecture/01-architecture.md +125 -0
  231. package/docs/design/modular-architecture/02-module-map.md +116 -0
  232. package/docs/design/modular-architecture/03-builder-flows.md +104 -0
  233. package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
  234. package/docs/design/modular-architecture/README.md +27 -0
  235. package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
  236. package/docs/design/world-map-scroll.md +41 -0
  237. package/docs/design-contract.md +320 -0
  238. package/docs/file-map.md +418 -0
  239. package/docs/handoff-template.md +92 -0
  240. package/docs/modules-contract.md +293 -0
  241. package/docs/onboarding/browser-terminal-guide.md +73 -0
  242. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
  243. package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
  244. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
  245. package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
  246. package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
  247. package/docs/onboarding/diagrams/README.md +94 -0
  248. package/docs/onboarding/diagrams/assertions.json +170 -0
  249. package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
  250. package/docs/onboarding/diagrams/render-config.json +9 -0
  251. package/docs/onboarding/drachmae-and-karma.md +178 -0
  252. package/docs/onboarding/primer.md +202 -0
  253. package/docs/project-context.template.md +67 -0
  254. package/docs/recipes/README.md +35 -0
  255. package/docs/recipes/audit-replay.md +100 -0
  256. package/docs/recipes/bongos-cli-release.md +66 -0
  257. package/docs/recipes/builder-box-lifecycle.md +152 -0
  258. package/docs/recipes/claude-design-loop.md +143 -0
  259. package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
  260. package/docs/recipes/cloudbongos-staging-gate.md +139 -0
  261. package/docs/recipes/cloudbongos-standup.md +111 -0
  262. package/docs/recipes/devbox-app-release.md +93 -0
  263. package/docs/recipes/devbox-app-windows-test.md +128 -0
  264. package/docs/recipes/failed-ship-recovery-proof.md +102 -0
  265. package/docs/recipes/figma-design-loop.md +129 -0
  266. package/docs/recipes/gds-db-backup.md +255 -0
  267. package/docs/recipes/instance-deploy.md +37 -0
  268. package/docs/recipes/managed-settings-remote-control.md +416 -0
  269. package/docs/recipes/memory-backup.md +71 -0
  270. package/docs/recipes/memory-sync-conflicts.md +98 -0
  271. package/docs/recipes/multi-builder-merge.md +78 -0
  272. package/docs/recipes/ops-gotchas.md +212 -0
  273. package/docs/recipes/overnight-builder.md +53 -0
  274. package/docs/recipes/packaging-the-core.md +49 -0
  275. package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
  276. package/docs/recipes/parallel-load-test.md +32 -0
  277. package/docs/recipes/pixel-art-pipeline.md +411 -0
  278. package/docs/recipes/private-npm-distribution.md +79 -0
  279. package/docs/recipes/search-index-ingestion.md +67 -0
  280. package/docs/recipes/self-host.md +60 -0
  281. package/docs/recipes/ship-and-regrade.md +72 -0
  282. package/docs/recipes/staging-data.md +50 -0
  283. package/docs/recipes/staging-environment.md +215 -0
  284. package/docs/recipes/upgrading-the-core.md +58 -0
  285. package/docs/research/cursor-competitive-analysis.md +255 -0
  286. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
  287. package/migrations/003_pms.sql +284 -0
  288. package/migrations/004_pms_cost_idempotency.sql +13 -0
  289. package/migrations/005_pms_smoke_filter.sql +44 -0
  290. package/migrations/006_pms_v2.sql +378 -0
  291. package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
  292. package/migrations/008_pms_v2_migration_allocator.sql +27 -0
  293. package/migrations/009_pms_v2_blockers_table.sql +68 -0
  294. package/migrations/010_uptime.sql +29 -0
  295. package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
  296. package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
  297. package/migrations/013_claim_locking.sql +22 -0
  298. package/migrations/014_pms_task_discipline.sql +86 -0
  299. package/migrations/015_pms_estimation_calibration.sql +140 -0
  300. package/migrations/016_pms_task_grades.sql +60 -0
  301. package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
  302. package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
  303. package/migrations/020_task_dependencies.sql +99 -0
  304. package/migrations/021_v3_renumber_and_deps.sql +224 -0
  305. package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
  306. package/migrations/023_pms_rank.sql +30 -0
  307. package/migrations/025_builder_exit_status.sql +15 -0
  308. package/migrations/027_builders_preferred_disciplines.sql +46 -0
  309. package/migrations/028_tasks_parallel_safe.sql +71 -0
  310. package/migrations/029_rank_three_live.sql +32 -0
  311. package/migrations/030_vulnerability_reports.sql +61 -0
  312. package/migrations/031_reward_simplification.sql +57 -0
  313. package/migrations/032_repo_health_snapshots.sql +56 -0
  314. package/migrations/033_audit_log.sql +64 -0
  315. package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
  316. package/migrations/035_auth_token_hygiene.sql +66 -0
  317. package/migrations/037_builder_skill_model_prefs.sql +44 -0
  318. package/migrations/038_task_override_requests.sql +51 -0
  319. package/migrations/039_newcomer_friendly_column.sql +124 -0
  320. package/migrations/040_builder_karma.sql +58 -0
  321. package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
  322. package/migrations/042_criterion_proposal_workflow.sql +78 -0
  323. package/migrations/043_builder_onboarding_observability.sql +66 -0
  324. package/migrations/044_tasks_requires_rank.sql +97 -0
  325. package/migrations/045_builder_onboarding_state.sql +147 -0
  326. package/migrations/046_cost_log_builder_attribution.sql +42 -0
  327. package/migrations/054_builder_memory.sql +108 -0
  328. package/migrations/055_task_criteria.sql +90 -0
  329. package/migrations/058_peer_votes.sql +41 -0
  330. package/migrations/059_onboarding_pick_disciplines.sql +59 -0
  331. package/migrations/060_access_requests.sql +46 -0
  332. package/migrations/061_backfill_override_ship_attribution.sql +29 -0
  333. package/migrations/062_session_records.sql +34 -0
  334. package/migrations/063_session_records_tool_errors_idx.sql +11 -0
  335. package/migrations/064_grade_attempts.sql +18 -0
  336. package/migrations/065_bfg_principal.sql +58 -0
  337. package/migrations/066_live_notify_triggers.sql +69 -0
  338. package/migrations/067_builder_boxes.sql +117 -0
  339. package/migrations/067_discord_bot_foundation.sql +69 -0
  340. package/migrations/068_thetes_requires_rank.sql +27 -0
  341. package/migrations/069_builder_ssh_keys.sql +66 -0
  342. package/migrations/070_box_intents.sql +101 -0
  343. package/migrations/071_box_first_provision_approval.sql +46 -0
  344. package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
  345. package/migrations/073_box_terminal_access.sql +46 -0
  346. package/migrations/075_builder_sound_prefs.sql +28 -0
  347. package/migrations/078_box_claude_active.sql +22 -0
  348. package/migrations/079_box_first_connected_at.sql +20 -0
  349. package/migrations/081_tasks_needs_migration.sql +34 -0
  350. package/migrations/084_tasks_security_sensitive.sql +24 -0
  351. package/migrations/085_security_engagement_docs.sql +34 -0
  352. package/migrations/086_rename_disciplines.sql +125 -0
  353. package/migrations/088_builders_anthropic_link.sql +28 -0
  354. package/migrations/089_builders_monthly_budget.sql +19 -0
  355. package/migrations/090_box_scoped_sessions.sql +38 -0
  356. package/migrations/091_session_token_reward.sql +44 -0
  357. package/migrations/093_tasks_sandbox_review.sql +22 -0
  358. package/migrations/102_remove_box_first_provision_approval.sql +58 -0
  359. package/migrations/103_search_chunks.sql +90 -0
  360. package/migrations/104_builder_box_blocked.sql +34 -0
  361. package/migrations/105_box_host_keys.sql +35 -0
  362. package/migrations/111_medusa_customer0_framing.sql +35 -0
  363. package/migrations/112_drop_track_check.sql +25 -0
  364. package/migrations/113_merge_lease.sql +16 -0
  365. package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
  366. package/migrations/118_currency_label_decoupling.sql +23 -0
  367. package/migrations/119_builder_behavior_prefs.sql +31 -0
  368. package/migrations/121_box_code_version.sql +37 -0
  369. package/migrations/122_builder_gemini_key.sql +30 -0
  370. package/migrations/123_cost_log_tiers.sql +33 -0
  371. package/migrations/124_cost_baseline.sql +45 -0
  372. package/migrations/125_llm_cache.sql +54 -0
  373. package/migrations/126_autonomy_runs.sql +33 -0
  374. package/migrations/143_cost_calibration.sql +63 -0
  375. package/migrations/145_backfill_task_requires_rank.sql +84 -0
  376. package/migrations/160_goal_tier.sql +88 -0
  377. package/migrations/163_polymorphic_dependencies.sql +187 -0
  378. package/migrations/164_event_sound_prefs.sql +20 -0
  379. package/migrations/165_task_module_key.sql +15 -0
  380. package/migrations/171_claims_published_head_sha.sql +24 -0
  381. package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
  382. package/migrations/175_task_grades_diff_hash.sql +9 -0
  383. package/migrations/176_promotion_respects_open_blockers.sql +58 -0
  384. package/migrations/176_ui_discipline.sql +40 -0
  385. package/migrations/177_require_reward_before_workable.sql +76 -0
  386. package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
  387. package/migrations/179_search_chunk_usefulness.sql +33 -0
  388. package/migrations/180_one_active_claim_per_session.sql +54 -0
  389. package/migrations/181_cost_log_skill_name.sql +33 -0
  390. package/migrations/184_claim_release_requested.sql +25 -0
  391. package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
  392. package/migrations/187_goal_sort_order.sql +30 -0
  393. package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
  394. package/migrations/README.md +88 -0
  395. package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
  396. package/migrations/core_189_core_upgrades_ledger.sql +37 -0
  397. package/migrations/core_189_credit_log_task_id_index.sql +19 -0
  398. package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
  399. package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
  400. package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
  401. package/migrations/instance/110_medusa_planning_seed.sql +70 -0
  402. package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
  403. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
  404. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
  405. package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
  406. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
  407. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
  408. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
  409. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
  410. package/modules/autonomy/CLAUDE.md +49 -0
  411. package/modules/autonomy/db.js +50 -0
  412. package/modules/autonomy/module.json +13 -0
  413. package/modules/autonomy/pollers/routine-timers.js +146 -0
  414. package/modules/autonomy/routes/autonomy.js +37 -0
  415. package/modules/builder-settings/CLAUDE.md +58 -0
  416. package/modules/builder-settings/builder-needs.js +115 -0
  417. package/modules/builder-settings/db.js +168 -0
  418. package/modules/builder-settings/display-name.js +84 -0
  419. package/modules/builder-settings/event-sound-prefs.js +169 -0
  420. package/modules/builder-settings/interaction-prefs.js +201 -0
  421. package/modules/builder-settings/model-alloc-prefs.js +147 -0
  422. package/modules/builder-settings/module.json +10 -0
  423. package/modules/builder-settings/render-prefs.js +103 -0
  424. package/modules/builder-settings/routes/settings.js +417 -0
  425. package/modules/builder-settings/settings.js +52 -0
  426. package/modules/builder-settings/skill-prefs.js +196 -0
  427. package/modules/builder-settings/sound-prefs.js +178 -0
  428. package/modules/builder-settings/wandering-prefs.js +282 -0
  429. package/modules/dev-box/CLAUDE.md +38 -0
  430. package/modules/dev-box/box-access.js +275 -0
  431. package/modules/dev-box/box-credential.js +94 -0
  432. package/modules/dev-box/box-onboard.js +486 -0
  433. package/modules/dev-box/boxes.js +842 -0
  434. package/modules/dev-box/db.js +39 -0
  435. package/modules/dev-box/module.json +14 -0
  436. package/modules/dev-box/routes/box.js +977 -0
  437. package/modules/discord/CLAUDE.md +50 -0
  438. package/modules/discord/auth.js +54 -0
  439. package/modules/discord/bug-attachments.js +210 -0
  440. package/modules/discord/db.js +96 -0
  441. package/modules/discord/discord-approvals.js +145 -0
  442. package/modules/discord/discord-audit.js +29 -0
  443. package/modules/discord/discord-bot.js +545 -0
  444. package/modules/discord/discord-bugs.js +257 -0
  445. package/modules/discord/discord-channels.js +412 -0
  446. package/modules/discord/discord-inbound.js +147 -0
  447. package/modules/discord/discord-message-buffer.js +54 -0
  448. package/modules/discord/discord-reaction.js +186 -0
  449. package/modules/discord/discord-role-sync.js +318 -0
  450. package/modules/discord/module.json +14 -0
  451. package/modules/discord/pollers/discord-bot.js +19 -0
  452. package/modules/discord/routes/bug-attachments.js +44 -0
  453. package/modules/discord/routes/discord.js +102 -0
  454. package/modules/discord/ship-broadcast.js +78 -0
  455. package/modules/discord/webhook.js +74 -0
  456. package/modules/economy/CLAUDE.md +48 -0
  457. package/modules/economy/achievements.js +594 -0
  458. package/modules/economy/cost-classify.js +331 -0
  459. package/modules/economy/cost-summary-shape.js +90 -0
  460. package/modules/economy/cost.js +438 -0
  461. package/modules/economy/credits.js +430 -0
  462. package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
  463. package/modules/economy/module.json +10 -0
  464. package/modules/economy/reward.js +61 -0
  465. package/modules/economy/routes/cost.js +97 -0
  466. package/modules/economy/routes/leaderboard.js +26 -0
  467. package/modules/economy/routes/reward.js +42 -0
  468. package/modules/economy/streak.js +285 -0
  469. package/modules/grading/CLAUDE.md +42 -0
  470. package/modules/grading/db.js +39 -0
  471. package/modules/grading/grader-rubric.json +151 -0
  472. package/modules/grading/grader-workers/efficiency.js +213 -0
  473. package/modules/grading/grader-workers/extract-json.js +80 -0
  474. package/modules/grading/grader-workers/fence.js +100 -0
  475. package/modules/grading/grader-workers/hacker.js +239 -0
  476. package/modules/grading/grader-workers/index.js +183 -0
  477. package/modules/grading/grader-workers/narc.js +207 -0
  478. package/modules/grading/grader-workers/quality.js +198 -0
  479. package/modules/grading/grader-workers/rubric.js +20 -0
  480. package/modules/grading/grader-workers/worker-prompt.js +89 -0
  481. package/modules/grading/grader-workers/worker-verdict.js +50 -0
  482. package/modules/grading/grader.js +1790 -0
  483. package/modules/grading/module.json +12 -0
  484. package/modules/grading/routes/grade.js +51 -0
  485. package/modules/hall-ui/CLAUDE.md +61 -0
  486. package/modules/hall-ui/module.json +14 -0
  487. package/modules/hall-ui/public/atlas.html +180 -0
  488. package/modules/hall-ui/public/atlas.js +336 -0
  489. package/modules/hall-ui/public/box-panel.js +423 -0
  490. package/modules/hall-ui/public/brand-apply.js +117 -0
  491. package/modules/hall-ui/public/builders.js +3028 -0
  492. package/modules/hall-ui/public/chime.wav +0 -0
  493. package/modules/hall-ui/public/copy-on-click.js +174 -0
  494. package/modules/hall-ui/public/cursors/arrow.png +0 -0
  495. package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
  496. package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
  497. package/modules/hall-ui/public/cursors/magnifier.png +0 -0
  498. package/modules/hall-ui/public/diagram-viewer.css +125 -0
  499. package/modules/hall-ui/public/diagram-viewer.js +330 -0
  500. package/modules/hall-ui/public/diagrams.html +113 -0
  501. package/modules/hall-ui/public/dom-utils.js +212 -0
  502. package/modules/hall-ui/public/drachmae.html +68 -0
  503. package/modules/hall-ui/public/drachmae.js +136 -0
  504. package/modules/hall-ui/public/gate.html +143 -0
  505. package/modules/hall-ui/public/gate.js +228 -0
  506. package/modules/hall-ui/public/goals-page.js +1297 -0
  507. package/modules/hall-ui/public/goals.html +376 -0
  508. package/modules/hall-ui/public/goals.js +119 -0
  509. package/modules/hall-ui/public/hall-widgets.js +189 -0
  510. package/modules/hall-ui/public/harbor.html +167 -0
  511. package/modules/hall-ui/public/harbor.js +335 -0
  512. package/modules/hall-ui/public/index.html +116 -0
  513. package/modules/hall-ui/public/instances-panel.js +172 -0
  514. package/modules/hall-ui/public/md-reader.js +234 -0
  515. package/modules/hall-ui/public/modules.html +119 -0
  516. package/modules/hall-ui/public/modules.js +154 -0
  517. package/modules/hall-ui/public/new-project-wizard.js +186 -0
  518. package/modules/hall-ui/public/not-ready.html +48 -0
  519. package/modules/hall-ui/public/pair.html +119 -0
  520. package/modules/hall-ui/public/pair.js +132 -0
  521. package/modules/hall-ui/public/primer.css +404 -0
  522. package/modules/hall-ui/public/primer.html +85 -0
  523. package/modules/hall-ui/public/primer.js +641 -0
  524. package/modules/hall-ui/public/profile.html +85 -0
  525. package/modules/hall-ui/public/profile.js +236 -0
  526. package/modules/hall-ui/public/ranks.html +69 -0
  527. package/modules/hall-ui/public/ranks.js +206 -0
  528. package/modules/hall-ui/public/roadmap.html +209 -0
  529. package/modules/hall-ui/public/roadmap.js +375 -0
  530. package/modules/hall-ui/public/sessions.html +92 -0
  531. package/modules/hall-ui/public/sessions.js +222 -0
  532. package/modules/hall-ui/public/settings.html +640 -0
  533. package/modules/hall-ui/public/settings.js +1219 -0
  534. package/modules/hall-ui/public/shell.js +495 -0
  535. package/modules/hall-ui/public/style.css +1748 -0
  536. package/modules/hall-ui/public/task-detail.js +253 -0
  537. package/modules/hall-ui/public/task.html +83 -0
  538. package/modules/hall-ui/public/task.js +326 -0
  539. package/modules/hall-ui/public/watch.html +333 -0
  540. package/modules/hall-ui/public/watch.js +859 -0
  541. package/modules/hall-ui/public/work.html +469 -0
  542. package/modules/hall-ui/public/work.js +1292 -0
  543. package/modules/ideas/CLAUDE.md +37 -0
  544. package/modules/ideas/blockers.js +100 -0
  545. package/modules/ideas/inbox.js +459 -0
  546. package/modules/ideas/module.json +10 -0
  547. package/modules/ideas/routes/blockers.js +120 -0
  548. package/modules/ideas/routes/inbox.js +230 -0
  549. package/modules/lifecycle/CLAUDE.md +63 -0
  550. package/modules/lifecycle/conflict-resolve.js +436 -0
  551. package/modules/lifecycle/db.js +4970 -0
  552. package/modules/lifecycle/done-when.js +355 -0
  553. package/modules/lifecycle/github-push.js +969 -0
  554. package/modules/lifecycle/goal-conflicts.js +133 -0
  555. package/modules/lifecycle/hierarchy.js +104 -0
  556. package/modules/lifecycle/lifecycle.js +58 -0
  557. package/modules/lifecycle/merge-lock.js +261 -0
  558. package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
  559. package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
  560. package/modules/lifecycle/module.json +13 -0
  561. package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
  562. package/modules/lifecycle/publish-reconciler.js +418 -0
  563. package/modules/lifecycle/routes/analytics.js +63 -0
  564. package/modules/lifecycle/routes/claims.js +506 -0
  565. package/modules/lifecycle/routes/dependencies.js +82 -0
  566. package/modules/lifecycle/routes/done-when.js +136 -0
  567. package/modules/lifecycle/routes/gate-approvals.js +197 -0
  568. package/modules/lifecycle/routes/goals.js +1419 -0
  569. package/modules/lifecycle/routes/lifecycle.js +43 -0
  570. package/modules/lifecycle/routes/tasks.js +1703 -0
  571. package/modules/lifecycle/routes/versions.js +128 -0
  572. package/modules/lifecycle/ship-card.js +505 -0
  573. package/modules/lifecycle/ship-preflight.js +225 -0
  574. package/modules/lifecycle/task-classifier.js +512 -0
  575. package/modules/lifecycle/version-close.js +106 -0
  576. package/modules/memory/CLAUDE.md +40 -0
  577. package/modules/memory/learnings.js +275 -0
  578. package/modules/memory/memory.js +572 -0
  579. package/modules/memory/module.json +12 -0
  580. package/modules/memory/routes/learnings.js +90 -0
  581. package/modules/memory/routes/memory.js +916 -0
  582. package/modules/memory/routes/search.js +188 -0
  583. package/modules/memory/search-chunker.js +252 -0
  584. package/modules/memory/search.js +320 -0
  585. package/modules/onboarding/CLAUDE.md +55 -0
  586. package/modules/onboarding/approval-broadcast.js +59 -0
  587. package/modules/onboarding/db.js +54 -0
  588. package/modules/onboarding/module.json +10 -0
  589. package/modules/onboarding/newcomer-restock.js +212 -0
  590. package/modules/onboarding/onboarding-state.js +532 -0
  591. package/modules/onboarding/onboarding.js +508 -0
  592. package/modules/onboarding/port.js +63 -0
  593. package/modules/onboarding/routes/access-requests.js +208 -0
  594. package/modules/onboarding/routes/onboarding.js +54 -0
  595. package/modules/security/CLAUDE.md +44 -0
  596. package/modules/security/module.json +10 -0
  597. package/modules/security/routes/reports.js +251 -0
  598. package/modules/security/secret-scrub.js +201 -0
  599. package/modules/security/security.js +294 -0
  600. package/modules/sessions/CLAUDE.md +39 -0
  601. package/modules/sessions/bfg.js +357 -0
  602. package/modules/sessions/db.js +326 -0
  603. package/modules/sessions/module.json +10 -0
  604. package/modules/sessions/pulse.js +149 -0
  605. package/modules/sessions/routes/sessions.js +331 -0
  606. package/modules/status-ui/CLAUDE.md +47 -0
  607. package/modules/status-ui/module.json +14 -0
  608. package/modules/status-ui/public/README.md +29 -0
  609. package/modules/status-ui/public/cursors/arrow.png +0 -0
  610. package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
  611. package/modules/status-ui/public/index.html +363 -0
  612. package/modules/status-ui/public/snapshots/.gitkeep +0 -0
  613. package/modules/status-ui/public/status.js +1375 -0
  614. package/modules/status-ui/public/style.css +1342 -0
  615. package/package-lock.json +975 -0
  616. package/package.json +28 -0
  617. package/public-docs/index.html +35 -0
  618. package/public-docs/redoc.standalone.js +1838 -0
  619. package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
  620. package/public-landing/buddha.png +0 -0
  621. package/public-landing/create.html +868 -0
  622. package/public-landing/index.html +168 -0
  623. package/scripts/bongos-mcp.js +28 -0
  624. package/scripts/cron/style_guide_audit.sh +134 -0
  625. package/scripts/discord/README.md +52 -0
  626. package/scripts/discord/lib.js +80 -0
  627. package/scripts/discord/post.js +51 -0
  628. package/scripts/gds/CLAUDE.md +144 -0
  629. package/scripts/gds/abandon.js +58 -0
  630. package/scripts/gds/adopt-detect.js +123 -0
  631. package/scripts/gds/adopt-import.js +111 -0
  632. package/scripts/gds/adopt-preflight.js +121 -0
  633. package/scripts/gds/api.js +78 -0
  634. package/scripts/gds/architect-audit.js +288 -0
  635. package/scripts/gds/art-stage.js +152 -0
  636. package/scripts/gds/audit-authorship.sh +243 -0
  637. package/scripts/gds/audit-deps.js +121 -0
  638. package/scripts/gds/audit-exports.js +128 -0
  639. package/scripts/gds/audit-replay.sh +161 -0
  640. package/scripts/gds/audit-ships.js +251 -0
  641. package/scripts/gds/autonomy-gate.js +135 -0
  642. package/scripts/gds/backfill-cost.js +165 -0
  643. package/scripts/gds/backfill-sessions.js +351 -0
  644. package/scripts/gds/backfill-task-rewards.js +408 -0
  645. package/scripts/gds/backup.js +94 -0
  646. package/scripts/gds/bfg-principal-token.js +65 -0
  647. package/scripts/gds/box-auth-check.js +160 -0
  648. package/scripts/gds/box-connect-lib.js +453 -0
  649. package/scripts/gds/box.js +1258 -0
  650. package/scripts/gds/build-bongos.js +94 -0
  651. package/scripts/gds/capture.js +123 -0
  652. package/scripts/gds/cf-tunnel.js +419 -0
  653. package/scripts/gds/ci-grade.js +82 -0
  654. package/scripts/gds/claim-recall.js +112 -0
  655. package/scripts/gds/claim.js +333 -0
  656. package/scripts/gds/classify-discipline.js +153 -0
  657. package/scripts/gds/claude-materialize.js +176 -0
  658. package/scripts/gds/cleanup-orphans.js +74 -0
  659. package/scripts/gds/cli-lib.js +518 -0
  660. package/scripts/gds/code.js +101 -0
  661. package/scripts/gds/codemod-rename-src-gds.js +145 -0
  662. package/scripts/gds/conductor-dispatch.js +426 -0
  663. package/scripts/gds/context-pack.js +228 -0
  664. package/scripts/gds/coreB-carve-proof.sh +716 -0
  665. package/scripts/gds/cost.js +47 -0
  666. package/scripts/gds/db-backup-nightly.sh +160 -0
  667. package/scripts/gds/db-backup-offsite.sh +152 -0
  668. package/scripts/gds/demote.js +40 -0
  669. package/scripts/gds/design-sync.js +62 -0
  670. package/scripts/gds/diagnose-achievements.js +118 -0
  671. package/scripts/gds/diagram-facts.js +319 -0
  672. package/scripts/gds/discipline-modes.json +25 -0
  673. package/scripts/gds/discord-channels.js +66 -0
  674. package/scripts/gds/do-api.js +176 -0
  675. package/scripts/gds/docs-entropy.js +403 -0
  676. package/scripts/gds/doctor.js +389 -0
  677. package/scripts/gds/feedback-latest.js +94 -0
  678. package/scripts/gds/fetch-art-key.js +106 -0
  679. package/scripts/gds/fetch-sound-prefs.js +88 -0
  680. package/scripts/gds/figma-design-sync.js +61 -0
  681. package/scripts/gds/fitness.js +1446 -0
  682. package/scripts/gds/gate-review.js +322 -0
  683. package/scripts/gds/gating.js +325 -0
  684. package/scripts/gds/gds-db-backup.service +24 -0
  685. package/scripts/gds/gds-db-backup.timer +20 -0
  686. package/scripts/gds/gds-db-offsite.service +29 -0
  687. package/scripts/gds/gds-db-offsite.timer +18 -0
  688. package/scripts/gds/gen-api-client.js +508 -0
  689. package/scripts/gds/gen-api-docs.js +1037 -0
  690. package/scripts/gds/gen-atlas.js +366 -0
  691. package/scripts/gds/gen-diagrams.js +608 -0
  692. package/scripts/gds/gen-file-map.js +196 -0
  693. package/scripts/gds/gen-repo-map.js +519 -0
  694. package/scripts/gds/gen-session-index.js +210 -0
  695. package/scripts/gds/git-merge-regen.js +147 -0
  696. package/scripts/gds/grade-smoke.js +205 -0
  697. package/scripts/gds/hooks/pre-push.js +169 -0
  698. package/scripts/gds/http-api-client.js +88 -0
  699. package/scripts/gds/init.js +903 -0
  700. package/scripts/gds/install-git-hooks.js +114 -0
  701. package/scripts/gds/key-card.js +213 -0
  702. package/scripts/gds/key-status.js +126 -0
  703. package/scripts/gds/land-watch.js +168 -0
  704. package/scripts/gds/learning-capture.js +145 -0
  705. package/scripts/gds/lib.sh +44 -0
  706. package/scripts/gds/linkify-refs.js +473 -0
  707. package/scripts/gds/local-preview-lib.js +159 -0
  708. package/scripts/gds/local-preview.js +95 -0
  709. package/scripts/gds/login.js +225 -0
  710. package/scripts/gds/main-audit.js +279 -0
  711. package/scripts/gds/me.js +88 -0
  712. package/scripts/gds/memory-map.js +174 -0
  713. package/scripts/gds/merge-lock.js +71 -0
  714. package/scripts/gds/migration-namespace.js +200 -0
  715. package/scripts/gds/mirror-lineage-check.js +319 -0
  716. package/scripts/gds/mirror-redact.js +305 -0
  717. package/scripts/gds/module.js +482 -0
  718. package/scripts/gds/newcomer-floor.config.json +13 -0
  719. package/scripts/gds/newcomer-floor.js +415 -0
  720. package/scripts/gds/newcomer-floor.templates.json +86 -0
  721. package/scripts/gds/newcomer-floor.test.js +235 -0
  722. package/scripts/gds/next-number.js +163 -0
  723. package/scripts/gds/oauth-secret.js +197 -0
  724. package/scripts/gds/onboard.js +217 -0
  725. package/scripts/gds/onboarding-analyze.js +226 -0
  726. package/scripts/gds/onboarding-config.js +178 -0
  727. package/scripts/gds/onboarding-ship.js +92 -0
  728. package/scripts/gds/package-core.js +684 -0
  729. package/scripts/gds/paste-token.js +131 -0
  730. package/scripts/gds/portability-smoke-art.sh +85 -0
  731. package/scripts/gds/portability-smoke-memory.sh +147 -0
  732. package/scripts/gds/provision.js +1324 -0
  733. package/scripts/gds/publish-manifest.js +307 -0
  734. package/scripts/gds/pulse.js +309 -0
  735. package/scripts/gds/push-main.js +45 -0
  736. package/scripts/gds/read-session-export.js +359 -0
  737. package/scripts/gds/reauth.js +149 -0
  738. package/scripts/gds/recall.js +135 -0
  739. package/scripts/gds/release.js +71 -0
  740. package/scripts/gds/render-ideas.js +173 -0
  741. package/scripts/gds/repo-metrics.js +363 -0
  742. package/scripts/gds/resolve-skill-model.js +57 -0
  743. package/scripts/gds/run-routine.js +197 -0
  744. package/scripts/gds/run-unit-tests.js +178 -0
  745. package/scripts/gds/sandbox-stage.js +653 -0
  746. package/scripts/gds/search-ingest-db.js +209 -0
  747. package/scripts/gds/search-ingest-md.js +225 -0
  748. package/scripts/gds/security-baseline-check.sh +180 -0
  749. package/scripts/gds/seed-adopt-tasks.js +75 -0
  750. package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
  751. package/scripts/gds/seed-bongos-app-tasks.js +294 -0
  752. package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
  753. package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
  754. package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
  755. package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
  756. package/scripts/gds/seed-bongos-tasks.js +214 -0
  757. package/scripts/gds/seed-gds-v4-tasks.js +624 -0
  758. package/scripts/gds/seed-governance-tasks.js +340 -0
  759. package/scripts/gds/seed-medusa-tasks.js +440 -0
  760. package/scripts/gds/seed-module-upstreaming.js +219 -0
  761. package/scripts/gds/seed-productization-tasks.js +235 -0
  762. package/scripts/gds/seed-provisioning-tasks.js +83 -0
  763. package/scripts/gds/seed-staging.sh +104 -0
  764. package/scripts/gds/seed-v2-world-tasks.js +283 -0
  765. package/scripts/gds/seed-v3-tasks.js +1981 -0
  766. package/scripts/gds/session-cost.js +393 -0
  767. package/scripts/gds/session-digest.js +179 -0
  768. package/scripts/gds/session-eval.js +576 -0
  769. package/scripts/gds/session.js +404 -0
  770. package/scripts/gds/setup.js +504 -0
  771. package/scripts/gds/shell.js +100 -0
  772. package/scripts/gds/ship.js +3618 -0
  773. package/scripts/gds/smoke-batch-claim.sh +213 -0
  774. package/scripts/gds/smoke-batch-release.sh +296 -0
  775. package/scripts/gds/smoke-box.sh +150 -0
  776. package/scripts/gds/smoke-claim-consistency.sh +212 -0
  777. package/scripts/gds/smoke-dependencies.sh +84 -0
  778. package/scripts/gds/smoke-gds.js +125 -0
  779. package/scripts/gds/smoke-write.sh +168 -0
  780. package/scripts/gds/start.js +782 -0
  781. package/scripts/gds/status.js +365 -0
  782. package/scripts/gds/triage.js +366 -0
  783. package/scripts/gds/upgrade.js +421 -0
  784. package/scripts/gds/validate-design.js +254 -0
  785. package/scripts/gds/version-close.js +549 -0
  786. package/scripts/migrate.sh +183 -0
  787. package/scripts/migrate_cost_ledger.sh +178 -0
  788. package/scripts/public-mirror-export.js +291 -0
  789. package/scripts/render-diagrams.sh +88 -0
  790. package/scripts/security/dep-audit.js +276 -0
  791. package/scripts/setup-builder.js +274 -0
  792. package/scripts/setup-builder.sh +28 -0
  793. package/scripts/snapshots/backfill-history.mjs +292 -0
  794. package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
  795. package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
  796. package/scripts/snapshots/render-world.mjs +322 -0
  797. package/scripts/status-mirror-sync.js +192 -0
  798. package/scripts/status-probe.mjs +185 -0
  799. package/server.js +212 -0
  800. package/src/asset-version.js +71 -0
  801. package/src/bongos/CLAUDE.md +64 -0
  802. package/src/bongos/api-errors.js +281 -0
  803. package/src/bongos/api-path-404.js +67 -0
  804. package/src/bongos/api-prefix.js +38 -0
  805. package/src/bongos/app-pair.js +172 -0
  806. package/src/bongos/auth.js +979 -0
  807. package/src/bongos/cascade-dispatch.js +123 -0
  808. package/src/bongos/db-kernel.js +252 -0
  809. package/src/bongos/db.js +911 -0
  810. package/src/bongos/goal-scope-check.js +120 -0
  811. package/src/bongos/hierarchy-config.js +189 -0
  812. package/src/bongos/live-channel.js +145 -0
  813. package/src/bongos/llm-cache.js +260 -0
  814. package/src/bongos/llm-pricing.js +220 -0
  815. package/src/bongos/metrics.js +286 -0
  816. package/src/bongos/middleware/audit.js +274 -0
  817. package/src/bongos/middleware/error-envelope.js +93 -0
  818. package/src/bongos/middleware/rate-limit.js +195 -0
  819. package/src/bongos/module-scope-map.js +271 -0
  820. package/src/bongos/optimizer.js +166 -0
  821. package/src/bongos/path-match.js +47 -0
  822. package/src/bongos/permission-path-check.js +248 -0
  823. package/src/bongos/pool.js +55 -0
  824. package/src/bongos/repo-info.js +91 -0
  825. package/src/bongos/route-rank-check.js +420 -0
  826. package/src/bongos/routes/CLAUDE.md +43 -0
  827. package/src/bongos/routes/_helpers.js +369 -0
  828. package/src/bongos/routes/audit-log.js +59 -0
  829. package/src/bongos/routes/auth.js +755 -0
  830. package/src/bongos/routes/backup.js +104 -0
  831. package/src/bongos/routes/builders.js +526 -0
  832. package/src/bongos/routes/healthz.js +16 -0
  833. package/src/bongos/routes/instance.js +129 -0
  834. package/src/bongos/routes/live.js +125 -0
  835. package/src/bongos/routes/llm-cache.js +119 -0
  836. package/src/bongos/routes/me.js +380 -0
  837. package/src/bongos/routes/modules.js +131 -0
  838. package/src/bongos/routes/optimizer.js +54 -0
  839. package/src/bongos/routes/public.js +476 -0
  840. package/src/bongos/routes/security.js +207 -0
  841. package/src/bongos/routes.js +166 -0
  842. package/src/bongos/secret-box.js +177 -0
  843. package/src/bongos/sensitive-surfaces.js +30 -0
  844. package/src/bongos/serve-internal.js +889 -0
  845. package/src/bongos/uptime-poller.js +90 -0
  846. package/src/bongos-downloads.js +70 -0
  847. package/src/branding.js +182 -0
  848. package/src/build-info.js +77 -0
  849. package/src/devbox-downloads.js +67 -0
  850. package/src/instance-config.js +200 -0
  851. package/src/module-api.js +366 -0
  852. package/src/module-loader/loader.js +268 -0
  853. package/src/module-loader/manifest-schema.js +140 -0
  854. package/src/module-loader/provenance.js +28 -0
  855. package/src/module-loader/semver.js +100 -0
  856. package/src/module-seams.js +291 -0
  857. package/src/modules.js +254 -0
  858. package/src/platform-server.js +135 -0
  859. package/src/ui/adapters/claude-design/index.js +333 -0
  860. package/src/ui/adapters/figma/index.js +328 -0
  861. package/tests/abandon_task.mjs +138 -0
  862. package/tests/achievements_reconcile.mjs +167 -0
  863. package/tests/adopt_detect.mjs +141 -0
  864. package/tests/adopt_import.mjs +132 -0
  865. package/tests/adopt_preflight.mjs +105 -0
  866. package/tests/anti_grid.mjs +89 -0
  867. package/tests/api_client.mjs +231 -0
  868. package/tests/api_docs.mjs +283 -0
  869. package/tests/api_error_envelope_buildcheck.mjs +56 -0
  870. package/tests/api_error_envelope_middleware.mjs +99 -0
  871. package/tests/api_error_registry.mjs +108 -0
  872. package/tests/api_pagination_contract.mjs +51 -0
  873. package/tests/api_path_404.mjs +96 -0
  874. package/tests/app_pair.mjs +168 -0
  875. package/tests/architect.mjs +140 -0
  876. package/tests/art_key.mjs +121 -0
  877. package/tests/art_stage.mjs +121 -0
  878. package/tests/asset_version.mjs +94 -0
  879. package/tests/atlas.mjs +69 -0
  880. package/tests/audit_deps.mjs +74 -0
  881. package/tests/audit_middleware.mjs +385 -0
  882. package/tests/auth_admission_gate.mjs +164 -0
  883. package/tests/auth_anthropic_identity.mjs +95 -0
  884. package/tests/auth_duplicate_cookie.mjs +56 -0
  885. package/tests/auth_error_brand.mjs +77 -0
  886. package/tests/auth_flow_slice1.mjs +223 -0
  887. package/tests/auth_inactive_builder.mjs +94 -0
  888. package/tests/auth_page_gate.mjs +92 -0
  889. package/tests/auth_session_ttl.mjs +155 -0
  890. package/tests/autonomous_fit_protected_paths.mjs +115 -0
  891. package/tests/autonomy_gate.mjs +85 -0
  892. package/tests/autotile_variants.mjs +256 -0
  893. package/tests/autotile_xverify.mjs +133 -0
  894. package/tests/backfill_sessions.mjs +101 -0
  895. package/tests/backfill_task_rewards.mjs +152 -0
  896. package/tests/backup_cli.mjs +104 -0
  897. package/tests/bfg.mjs +327 -0
  898. package/tests/bfg_write.mjs +423 -0
  899. package/tests/bongos_box_connect.mjs +89 -0
  900. package/tests/bongos_dispatch.mjs +74 -0
  901. package/tests/bongos_downloads.mjs +118 -0
  902. package/tests/bongos_login.mjs +34 -0
  903. package/tests/box_access.mjs +186 -0
  904. package/tests/box_auth_check.mjs +114 -0
  905. package/tests/box_code_staleness.mjs +116 -0
  906. package/tests/box_connect_e2e.mjs +196 -0
  907. package/tests/box_cost.mjs +70 -0
  908. package/tests/box_dns_repoint.mjs +151 -0
  909. package/tests/box_host_keys.mjs +102 -0
  910. package/tests/box_onboard.mjs +317 -0
  911. package/tests/box_scope_session.mjs +115 -0
  912. package/tests/box_ship_permissions.mjs +71 -0
  913. package/tests/box_terminal_ssrf.mjs +90 -0
  914. package/tests/boxes.mjs +1255 -0
  915. package/tests/branding.mjs +88 -0
  916. package/tests/bug_attachments.mjs +189 -0
  917. package/tests/build_info.mjs +88 -0
  918. package/tests/builder_needs.mjs +99 -0
  919. package/tests/cap_newcomer_tasks.mjs +114 -0
  920. package/tests/cascade-dispatch.mjs +95 -0
  921. package/tests/cf_tunnel.mjs +459 -0
  922. package/tests/claim_recall.mjs +150 -0
  923. package/tests/claims_bind_session.mjs +47 -0
  924. package/tests/claude_materialize.mjs +148 -0
  925. package/tests/cli_box_scope.mjs +52 -0
  926. package/tests/cli_lib_formatters.mjs +87 -0
  927. package/tests/cli_token_reissue.mjs +147 -0
  928. package/tests/client_bundle_route.mjs +47 -0
  929. package/tests/cloud_bongos_pack.mjs +85 -0
  930. package/tests/conductor.mjs +496 -0
  931. package/tests/confirm_attribution.mjs +156 -0
  932. package/tests/conflict_resolve.mjs +462 -0
  933. package/tests/context_pack.mjs +240 -0
  934. package/tests/cost.mjs +94 -0
  935. package/tests/cost_audit.mjs +122 -0
  936. package/tests/cost_budget_status.mjs +78 -0
  937. package/tests/cost_reconcile.mjs +102 -0
  938. package/tests/cost_summary_shape.mjs +94 -0
  939. package/tests/credit_grant.mjs +376 -0
  940. package/tests/criterion_progress.mjs +220 -0
  941. package/tests/criterion_proposal.mjs +109 -0
  942. package/tests/criterion_ref_parse.mjs +53 -0
  943. package/tests/currency_label.mjs +119 -0
  944. package/tests/day_cycle.mjs +197 -0
  945. package/tests/dependencies_migration.mjs +77 -0
  946. package/tests/dependencies_polymorphic.mjs +212 -0
  947. package/tests/deploy_instance.mjs +78 -0
  948. package/tests/devbox_downloads.mjs +122 -0
  949. package/tests/diff_derived_signals.mjs +115 -0
  950. package/tests/discord_approvals.mjs +174 -0
  951. package/tests/discord_autojoin.mjs +158 -0
  952. package/tests/discord_bot.mjs +94 -0
  953. package/tests/discord_bugs.mjs +297 -0
  954. package/tests/discord_channels.mjs +337 -0
  955. package/tests/discord_foundation.mjs +76 -0
  956. package/tests/discord_inbound.mjs +190 -0
  957. package/tests/discord_message_buffer.mjs +87 -0
  958. package/tests/discord_oauth.mjs +55 -0
  959. package/tests/discord_reaction.mjs +170 -0
  960. package/tests/discord_role_sync.mjs +151 -0
  961. package/tests/display_name.mjs +123 -0
  962. package/tests/dns_preflight.mjs +74 -0
  963. package/tests/event_sound_prefs.mjs +194 -0
  964. package/tests/eviction_policy.mjs +279 -0
  965. package/tests/figma_adapter.mjs +199 -0
  966. package/tests/file_map.mjs +91 -0
  967. package/tests/first_admin_bootstrap.mjs +40 -0
  968. package/tests/fitness.mjs +435 -0
  969. package/tests/fitness_two_root.mjs +104 -0
  970. package/tests/fixtures/session-506-digest.json +34 -0
  971. package/tests/gate_approvals.mjs +451 -0
  972. package/tests/gate_review.mjs +152 -0
  973. package/tests/git_merge_regen.mjs +151 -0
  974. package/tests/github_push_ancestry.mjs +119 -0
  975. package/tests/github_push_land_proof.mjs +127 -0
  976. package/tests/github_push_merge_driver.mjs +175 -0
  977. package/tests/goal_abuse_matrix.mjs +187 -0
  978. package/tests/goal_achievement.mjs +146 -0
  979. package/tests/goal_conflicts.mjs +305 -0
  980. package/tests/goal_dependencies_read.mjs +73 -0
  981. package/tests/goal_member_roles.mjs +420 -0
  982. package/tests/goal_membership_requests.mjs +332 -0
  983. package/tests/goal_routes.mjs +203 -0
  984. package/tests/goal_scope_check.mjs +167 -0
  985. package/tests/goal_tier.mjs +312 -0
  986. package/tests/goal_wall_proof.mjs +112 -0
  987. package/tests/grade_attempts.mjs +73 -0
  988. package/tests/grade_attribution.mjs +117 -0
  989. package/tests/grade_diff_hash.mjs +191 -0
  990. package/tests/grade_server_authoritative.mjs +305 -0
  991. package/tests/grade_unavailable_display.mjs +66 -0
  992. package/tests/grader.mjs +1960 -0
  993. package/tests/grader_bypass.mjs +57 -0
  994. package/tests/grader_panel.mjs +252 -0
  995. package/tests/grader_prompt_fencing.mjs +192 -0
  996. package/tests/helpers.mjs +52 -0
  997. package/tests/hierarchy-config.mjs +81 -0
  998. package/tests/init.mjs +463 -0
  999. package/tests/instance_config.mjs +76 -0
  1000. package/tests/instance_manifest.mjs +110 -0
  1001. package/tests/interaction_prefs.mjs +72 -0
  1002. package/tests/key-card.mjs +110 -0
  1003. package/tests/key-status.mjs +45 -0
  1004. package/tests/land_watch.mjs +115 -0
  1005. package/tests/lifecycle-card.mjs +145 -0
  1006. package/tests/lifecycle_breakdown_view.mjs +236 -0
  1007. package/tests/linkify_refs.mjs +264 -0
  1008. package/tests/live_channel.mjs +128 -0
  1009. package/tests/live_sse.mjs +122 -0
  1010. package/tests/llm-cache-route.mjs +110 -0
  1011. package/tests/llm-cache.mjs +140 -0
  1012. package/tests/llm-pricing.mjs +152 -0
  1013. package/tests/local_preview.mjs +136 -0
  1014. package/tests/main_audit.mjs +137 -0
  1015. package/tests/memory_isolation.mjs +431 -0
  1016. package/tests/memory_map.mjs +107 -0
  1017. package/tests/memory_pull.mjs +429 -0
  1018. package/tests/merge_lease.mjs +100 -0
  1019. package/tests/merge_lock.mjs +312 -0
  1020. package/tests/metrics.mjs +147 -0
  1021. package/tests/migration_allocation.mjs +146 -0
  1022. package/tests/migration_namespace.mjs +185 -0
  1023. package/tests/migrations_fresh_replay_guard.mjs +38 -0
  1024. package/tests/mirror_lineage_fulltree.mjs +45 -0
  1025. package/tests/mirror_lineage_proof.mjs +72 -0
  1026. package/tests/mirror_redact.mjs +193 -0
  1027. package/tests/model_alloc_prefs.mjs +63 -0
  1028. package/tests/module-scope-map.mjs +88 -0
  1029. package/tests/module_api.mjs +110 -0
  1030. package/tests/module_cli.mjs +292 -0
  1031. package/tests/module_contributions.mjs +148 -0
  1032. package/tests/module_loader.mjs +286 -0
  1033. package/tests/module_manifest.mjs +194 -0
  1034. package/tests/module_owned_tests.mjs +105 -0
  1035. package/tests/module_provenance.mjs +34 -0
  1036. package/tests/module_route_rank.mjs +147 -0
  1037. package/tests/module_seams.mjs +68 -0
  1038. package/tests/modules.mjs +156 -0
  1039. package/tests/modules_route.mjs +38 -0
  1040. package/tests/newcomer_floor.mjs +17 -0
  1041. package/tests/newcomer_restock.mjs +80 -0
  1042. package/tests/next_number.mjs +154 -0
  1043. package/tests/oauth_secret.mjs +60 -0
  1044. package/tests/onboard.mjs +70 -0
  1045. package/tests/onboarding_gate.mjs +267 -0
  1046. package/tests/onboarding_observability.mjs +344 -0
  1047. package/tests/onboarding_route_signals.mjs +175 -0
  1048. package/tests/onboarding_state.mjs +346 -0
  1049. package/tests/one_active_claim_per_session.mjs +111 -0
  1050. package/tests/orphaned_claim_ship.mjs +135 -0
  1051. package/tests/override_attribution.mjs +142 -0
  1052. package/tests/override_request_ownership.mjs +235 -0
  1053. package/tests/package_core.mjs +435 -0
  1054. package/tests/parallel-load.mjs +402 -0
  1055. package/tests/path_match.mjs +62 -0
  1056. package/tests/permission_path.mjs +151 -0
  1057. package/tests/platform_boot.mjs +167 -0
  1058. package/tests/pre_compact.mjs +60 -0
  1059. package/tests/preview_server.mjs +127 -0
  1060. package/tests/profile_route.mjs +197 -0
  1061. package/tests/provision.mjs +805 -0
  1062. package/tests/provisioning_github_app_routes.mjs +98 -0
  1063. package/tests/public_mirror_export.mjs +124 -0
  1064. package/tests/public_task_resolver.mjs +112 -0
  1065. package/tests/publish_branch_route.mjs +422 -0
  1066. package/tests/publish_manifest.mjs +184 -0
  1067. package/tests/publish_reconciler.mjs +728 -0
  1068. package/tests/rank_gate.mjs +463 -0
  1069. package/tests/rate_limit_public.mjs +217 -0
  1070. package/tests/read_session_export.mjs +89 -0
  1071. package/tests/recall_cli.mjs +82 -0
  1072. package/tests/recall_hints.mjs +119 -0
  1073. package/tests/recent_shipped_window.mjs +80 -0
  1074. package/tests/regrade_eligibility.mjs +202 -0
  1075. package/tests/render_prefs.mjs +122 -0
  1076. package/tests/repo_map.mjs +92 -0
  1077. package/tests/reward_gate.mjs +284 -0
  1078. package/tests/route_rank_check.mjs +243 -0
  1079. package/tests/route_validation.mjs +490 -0
  1080. package/tests/routine_timers.mjs +72 -0
  1081. package/tests/rubric_fixtures.mjs +140 -0
  1082. package/tests/run_routine.mjs +69 -0
  1083. package/tests/sandbox_stage.mjs +582 -0
  1084. package/tests/scrubber_corpus.mjs +257 -0
  1085. package/tests/search_chunker.mjs +209 -0
  1086. package/tests/search_ingest.mjs +96 -0
  1087. package/tests/search_ingest_db.mjs +88 -0
  1088. package/tests/search_isolation.mjs +204 -0
  1089. package/tests/search_route.mjs +95 -0
  1090. package/tests/secret_box.mjs +189 -0
  1091. package/tests/security_docs.mjs +186 -0
  1092. package/tests/security_route.mjs +108 -0
  1093. package/tests/served_paths_shipped.mjs +118 -0
  1094. package/tests/session-cards.mjs +142 -0
  1095. package/tests/session_506_regression.mjs +69 -0
  1096. package/tests/session_caps.mjs +238 -0
  1097. package/tests/session_cost.mjs +133 -0
  1098. package/tests/session_digest.mjs +73 -0
  1099. package/tests/session_eval.mjs +386 -0
  1100. package/tests/session_index.mjs +105 -0
  1101. package/tests/session_records.mjs +369 -0
  1102. package/tests/session_search.mjs +78 -0
  1103. package/tests/session_start_freshness.mjs +123 -0
  1104. package/tests/setup_smoke.mjs +154 -0
  1105. package/tests/ship_autolinkify.mjs +60 -0
  1106. package/tests/ship_automerge_injection.mjs +174 -0
  1107. package/tests/ship_broadcast_routing.mjs +125 -0
  1108. package/tests/ship_broadcast_server.mjs +175 -0
  1109. package/tests/ship_card.mjs +280 -0
  1110. package/tests/ship_ci_deploy.mjs +374 -0
  1111. package/tests/ship_preflight.mjs +368 -0
  1112. package/tests/ship_premerge.mjs +75 -0
  1113. package/tests/ship_resume.mjs +129 -0
  1114. package/tests/ship_upload_skip.mjs +89 -0
  1115. package/tests/ship_zero_commit.mjs +60 -0
  1116. package/tests/single_host_hall.mjs +98 -0
  1117. package/tests/skill_prefs.mjs +150 -0
  1118. package/tests/smoke_preflight.mjs +66 -0
  1119. package/tests/snapshot_picker.mjs +233 -0
  1120. package/tests/sound_prefs.mjs +198 -0
  1121. package/tests/sprite_facing.mjs +35 -0
  1122. package/tests/status-probe.mjs +89 -0
  1123. package/tests/status_backfill.mjs +94 -0
  1124. package/tests/stop_refcheck.mjs +127 -0
  1125. package/tests/streak.mjs +175 -0
  1126. package/tests/subdomain_auth.mjs +58 -0
  1127. package/tests/task_classifier_vocab.mjs +41 -0
  1128. package/tests/task_create_idempotency.mjs +96 -0
  1129. package/tests/task_goal_link.mjs +93 -0
  1130. package/tests/task_ownership_gate.mjs +125 -0
  1131. package/tests/terrain_picker.mjs +298 -0
  1132. package/tests/touches_overlap.mjs +75 -0
  1133. package/tests/triage_development.mjs +140 -0
  1134. package/tests/turn_in_place.mjs +280 -0
  1135. package/tests/upgrade.mjs +357 -0
  1136. package/tests/verify_task_guard.mjs +64 -0
  1137. package/tests/version_close.mjs +76 -0
  1138. package/tests/versions_route.mjs +93 -0
  1139. package/tests/walkability.mjs +188 -0
  1140. package/tests/wandering_prefs.mjs +176 -0
  1141. package/tests/worktree_disambiguation.mjs +105 -0
  1142. package/tests/worktree_guard.mjs +90 -0
@@ -0,0 +1,1324 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
5
+ // (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
6
+ //
7
+ // WHAT IT IS. The trust-boundary-safe executor for the `provisioning` module.
8
+ // The internet-facing web tier can only ENQUEUE a provision/teardown intent into
9
+ // provisioning_intents (modules/provisioning/routes/provisioning.js) — it holds
10
+ // NO cloud tokens. THIS runner, driven by a systemd timer
11
+ // (provision-intent-runner.timer → `provision.js run-intents --apply`), is the
12
+ // SOLE holder of the DigitalOcean / Cloudflare tokens (from a chmod-600
13
+ // /etc/<inst>/box.env via systemd EnvironmentFile=). It drains the queue and
14
+ // composes the docs/recipes/cloudbongos-standup.md runbook into ONE idempotent,
15
+ // dry-run-default, drift-reconciling flow (ADR 0016 / ADR 0031 §9.4 trust boundary):
16
+ //
17
+ // server (co-tenant process OR dedicated droplet)
18
+ // → createdb + GDS-only migrate (MEDUSA_GDS_ONLY=1)
19
+ // → write config + per-instance /etc/<inst>/web.env
20
+ // → install + enable a per-instance systemd unit (+ allocate a port)
21
+ // → Caddy site block + reload
22
+ // → /healthz
23
+ //
24
+ // DESIGN — inherited from box.js / do-api.js:
25
+ // • DRY-RUN by DEFAULT. Without --apply, every step only PRINTS the command it
26
+ // WOULD run and mutates nothing (no DB writes, no shell, no cloud call). The
27
+ // LIVE leg is an explicit operator/timer `--apply`.
28
+ // • PRIVILEGE model (task 2066, ADR 0128). The intent runner runs as a NON-root
29
+ // user (User=lars) with passwordless sudo, which cannot itself write under /etc,
30
+ // reload systemd/Caddy, or peer-auth to Postgres as a superuser. PROVISION_SUDO=1
31
+ // escalates exactly those steps (sudo install / sudo systemctl / sudo -u postgres);
32
+ // default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
33
+ // • Injectable executor. `makeExec` returns a `run(cmd)` that is a pure logger
34
+ // in dry-run and child_process in --apply; tests pass a recording fake, so the
35
+ // whole runbook composition is unit-tested with no droplet / DB / network.
36
+ // • Idempotent. Re-running a half-done provision is safe (createdb tolerates
37
+ // "already exists"; unit/Caddy writes are overwrites; healthz is a poll).
38
+ // • The step-command generators are PURE functions (exported) → the SAME command
39
+ // strings drive local exec (co-tenant) AND the dedicated droplet's cloud-init.
40
+ //
41
+ // This is control-plane ops tooling (scripts/), so — like box.js — it may require
42
+ // the core + a module's data slice directly (a module may not, but a CLI may). It
43
+ // reuses modules/provisioning/provisioning.js (the intent queue + state) and
44
+ // scripts/gds/do-api.js (the shared DO client extracted in task #1943).
45
+
46
+ const fs = require('node:fs');
47
+ const os = require('node:os');
48
+ const path = require('node:path');
49
+ const cp = require('node:child_process');
50
+
51
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
52
+
53
+ // The verified OAuth-cred placement helpers (task 1979) — reused by the GitHub App
54
+ // Manifest flow (task 2080) to VALIDATE the creds GitHub returns before writing them
55
+ // and to PARSE the post-restart /healthz auth_configured probe. A sibling script; a
56
+ // plain require (its require.main guard means importing it runs no CLI).
57
+ const oauthSecret = require('./oauth-secret');
58
+
59
+ // The HTTP User-Agent the manifest conversion stamps its GitHub call with (GitHub
60
+ // requires a UA). Overridable so a fork can identify itself; not secret.
61
+ const MANIFEST_UA = process.env.PROVISION_GITHUB_UA || 'cloudbongos-provisioner';
62
+
63
+ // The running core version (no caret) — for naming the vendored tarball the standalone
64
+ // scaffold pins. Fail-soft (a partial checkout returns null; the scaffold then errors
65
+ // with guidance rather than pinning a `…-null.tgz`). Mirrors init.js coreVersionSafe().
66
+ function coreVersionSafe() {
67
+ try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
68
+ }
69
+
70
+ function numEnv(name, dflt) {
71
+ const v = process.env[name];
72
+ if (v == null || v === '') return dflt;
73
+ const n = Number(v);
74
+ return Number.isFinite(n) && n > 0 ? n : dflt;
75
+ }
76
+ // A boolean env flag: '1'/'true'/'yes'/'on' (case-insensitive) → true, else false.
77
+ function boolEnv(name) {
78
+ return /^(1|true|yes|on)$/i.test(String(process.env[name] || '').trim());
79
+ }
80
+ function hasFlag(args, name) { return args.includes(name); }
81
+
82
+ const CONFIG = {
83
+ // The port range a CO-TENANT instance process binds on the shared box. 3000 (OTB)
84
+ // and 3002 (cloudbongos.com) are taken; new co-tenant instances start at 3003.
85
+ portBase: numEnv('PROVISION_PORT_BASE', 3003),
86
+ reservedPorts: (process.env.PROVISION_RESERVED_PORTS || '3000,3001,3002')
87
+ .split(',').map((s) => Number(s.trim())).filter(Number.isFinite),
88
+ // Where a per-instance secrets file lives: /etc/<slug>/web.env (the standup
89
+ // runbook's /etc/cloudbongos/web.env shape). chmod 600, OUTSIDE the repo.
90
+ etcBase: process.env.PROVISION_ETC_BASE || '/etc',
91
+ // The dir holding per-instance Caddy site snippets. Each instance gets its OWN
92
+ // <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
93
+ // the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
94
+ caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
95
+ // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
96
+ region: process.env.PROVISION_REGION || 'nyc3',
97
+ baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
98
+ tag: process.env.PROVISION_TAG || 'cloudbongos-instance',
99
+ doToken: process.env.DO_API_TOKEN || null,
100
+ sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
101
+ // The Cloudflare DNS hook (the SAME infra/box-dns-cloudflare.sh box.js uses).
102
+ // P4 (#1945) formalizes the UPSERT: point the instance's domain at the target
103
+ // public IP (below), in the domain's OWN Cloudflare zone, proxied=false (see
104
+ // dnsUpsertEnv). No hook configured → DNS is skipped (a co-tenant instance is
105
+ // still reachable by IP; TLS then can't be issued until DNS lands).
106
+ dnsHook: process.env.BOX_DNS_HOOK || null,
107
+ // This box's PUBLIC IP — the A-record target for a CO-TENANT instance's domain.
108
+ // Set in the control-plane /etc/<inst>/box.env (never the web tier). A dedicated
109
+ // instance instead points its A record at the freshly-created droplet's IP. Under
110
+ // remote-exec (PROVISION_REMOTE_HOST) this is the CO-HOSTING box's IP — where the
111
+ // co-tenant actually runs (ADR 0130).
112
+ publicIp: process.env.PROVISION_PUBLIC_IP || process.env.BOX_PUBLIC_IP || null,
113
+ // The CONTROL PLANE's OWN public IP — the A-record target for a STANDALONE instance
114
+ // (task 2074). A standalone runs control-plane-LOCAL (its scaffolded repo dir lives
115
+ // here, not on the remote co-hosting box), so its domain must point at THIS box, not
116
+ // at publicIp (which, under remote-exec, is the co-hosting box). Only needed when
117
+ // remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
118
+ // since the control plane and the run box are then the same machine.
119
+ controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
120
+ // Parent dir for a STANDALONE instance's OWN repo checkout (hosting_shape='standalone',
121
+ // ADR 0108): each lives at <standaloneBase>/<slug> with @cloudbongos/core installed as
122
+ // an npm dep. Distinct from co-tenant (which shares THIS control-plane checkout).
123
+ standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
124
+ // ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
125
+ // The intent runner runs as a NON-root user (provision-intent-runner.service:
126
+ // User=lars) that holds passwordless sudo. That user cannot itself write under
127
+ // /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
128
+ // runbook steps below FAIL as bare commands (the goal-35 blocker). With
129
+ // PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
130
+ // • /etc writes → temp file (lars-writable) + `sudo install -D -m <mode>`
131
+ // • systemctl / rm /etc → `sudo …`
132
+ // • createdb / migrate → `sudo -u postgres …` (postgres peer-auths as the PG
133
+ // superuser — the Ubuntu default — so it never needs `lars` to be a PG role)
134
+ // OFF (default) preserves the run-as-root / test / co-tenant-legacy behavior
135
+ // (bare commands, direct fs writes), so nothing outside a sudo context changes.
136
+ // Dry-run ALWAYS logs the exact command either way. NB: the runner unit must NOT
137
+ // set NoNewPrivileges=true — it blocks sudo (see infra/provision-intent-runner.service).
138
+ privileged: boolEnv('PROVISION_SUDO') || boolEnv('PROVISION_PRIVILEGED'),
139
+ // ── Remote orchestration over SSH (task 2065, ADR 0130) ──────────────────
140
+ // The token-separation endgame (ADR 0126): run this runner ON the token-holding
141
+ // control-plane box, and have it drive co-tenant workloads that still LIVE on the
142
+ // co-hosting box over SSH — so the co-hosting box can hold ZERO cloud tokens.
143
+ // When PROVISION_REMOTE_HOST is set, the BOX-MUTATING steps (createdb, migrate,
144
+ // /etc writes, systemctl, Caddy, healthz) run on that host over `ssh`; the
145
+ // TOKEN step (the Cloudflare DNS hook) + the standalone repo scaffold STAY local
146
+ // on the control plane, where the token + the packaging tools live. UNSET
147
+ // (default) = every step runs locally, byte-identical to today.
148
+ remoteHost: process.env.PROVISION_REMOTE_HOST || null,
149
+ // The co-hosting box's checkout dir — the cwd for the remote migrate (its relative
150
+ // ./scripts/migrate.sh). Defaults to that box's shared checkout.
151
+ remoteCwd: process.env.PROVISION_REMOTE_CHECKOUT || '/home/lars/amazonprimea',
152
+ // ssh options for the control-plane → co-hosting hop. BatchMode so a missing key
153
+ // fails fast (never hangs on a prompt); accept-new pins the host key on first
154
+ // connect (the operator can pre-seed known_hosts for strict pinning).
155
+ sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
156
+ };
157
+
158
+ const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
162
+ // ---------------------------------------------------------------------------
163
+ let _deps;
164
+ function loadDeps() {
165
+ if (_deps) return _deps;
166
+ // eslint-disable-next-line global-require
167
+ const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
168
+ // eslint-disable-next-line global-require
169
+ const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
170
+ // eslint-disable-next-line global-require
171
+ const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
172
+ _deps = { pool, provisioning, DoApi };
173
+ return _deps;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // PURE step-command generators (exported for tests). Each returns the exact shell
178
+ // command a runbook step runs. Kept pure so local exec (co-tenant) and the
179
+ // dedicated droplet's cloud-init both build from ONE source of truth.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ // A slug is validated DNS/db-safe upstream (provisioning.isValidSlug), so it is
183
+ // already shell-safe (only [a-z0-9-]). We still never interpolate untrusted text.
184
+ function dbName(inst) { return inst.db_name || inst.slug; }
185
+
186
+ // Escalate a DB command to run as the postgres OS user (→ PG superuser via peer auth,
187
+ // the Ubuntu default) when privileged — the DB-auth fix (task 2066). The runner's
188
+ // `lars` is not a usable PG role, so bare peer-auth-as-lars fails; `sudo -u postgres`
189
+ // sidesteps it without granting lars a role. PURE given the flag.
190
+ function asPostgres(cmd, privileged) { return privileged ? `sudo -u postgres ${cmd}` : cmd; }
191
+
192
+ function dbCreateCmd(inst, { privileged = false } = {}) {
193
+ // Idempotent: the caller runs it allowFail — "database already exists" is success.
194
+ // Non-privileged = bare `createdb` (peer auth as the running user, same as the
195
+ // standup runbook / OTB); privileged = `sudo -u postgres createdb` (task 2066).
196
+ return asPostgres(`createdb ${dbName(inst)}`, privileged);
197
+ }
198
+ function migrateCmd(inst, { privileged = false } = {}) {
199
+ // GDS-only schema — skips migrations/game/*.sql (no game tables on a vanilla
200
+ // instance), per the standup runbook (ADR 0105 instance seeds off). Privileged
201
+ // runs migrate as postgres (task 2066): `sudo` resets env, so the PGDATABASE/
202
+ // MEDUSA_GDS_ONLY vars ride `env …` and the script runs via `bash …` (a bare
203
+ // `VAR=val ./script` would be parsed BY sudo as a command). Precondition: the
204
+ // control-plane checkout must be readable by the postgres user (ADR 0128).
205
+ return privileged
206
+ ? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ./scripts/migrate.sh`
207
+ : `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 ./scripts/migrate.sh`;
208
+ }
209
+
210
+ // ── STANDALONE shape (hosting_shape='standalone', ADR 0108, task 1978) ────────
211
+ // A standalone instance runs from its OWN repo checkout (host content: config/,
212
+ // migrations/instance/, the core PIN in package.json) with @cloudbongos/core installed
213
+ // as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
214
+ // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
215
+ // scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
216
+ const CORE_PKG_DIR = 'node_modules/@cloudbongos/core';
217
+ function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
218
+ // Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
219
+ // step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
220
+ // precondition, like the runbook's `git pull`). Run with cwd = standaloneRoot.
221
+ function standalonePullCmd() { return `git pull --ff-only origin main`; }
222
+ // Install the pinned core + instance deps — replaces co-tenant's shared checkout
223
+ // (deploy-instance.sh step 2). Run with cwd = standaloneRoot.
224
+ function standaloneInstallCmd() { return `npm ci --omit=dev`; }
225
+ // Migrate core + instance from the INSTANCE root's copy of the three-root migrate.sh
226
+ // (the installed core's, falling back to an in-tree copy — deploy-instance.sh step 3).
227
+ // Run with cwd = standaloneRoot.
228
+ function standaloneMigrateCmd(inst, { privileged = false } = {}) {
229
+ // Privileged: run as postgres (task 2066), passing env via `env …` so it survives
230
+ // sudo's env reset (the instance root must be postgres-readable, ADR 0128).
231
+ return privileged
232
+ ? `sudo -u postgres env PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`
233
+ : `PGDATABASE=${dbName(inst)} MEDUSA_GDS_ONLY=1 bash ${CORE_PKG_DIR}/scripts/migrate.sh`;
234
+ }
235
+
236
+ // ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
237
+ // The MISSING first leg of the greenfield "one guided flow": provision.js used to
238
+ // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
239
+ // that clones an EMPTY target_ref and turns it into an installable, bootable instance.
240
+
241
+ // Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
242
+ // Null when unparseable, so the scaffold can fail with actionable guidance.
243
+ function parseTargetRef(ref) {
244
+ const m = String(ref || '').trim()
245
+ .replace(/^git@[^:]+:/, '')
246
+ .replace(/^https?:\/\/[^/]+\//, '')
247
+ .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
248
+ return m ? { owner: m[1], name: m[2] } : null;
249
+ }
250
+
251
+ // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
252
+ // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
253
+ // command (SSH-key auth, nothing to leak into journald), consistent with how the
254
+ // platform repo is wired. Standing up a new instance repo therefore needs only that
255
+ // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
256
+ // overridable via PROVISION_GIT_SSH_ALIAS.
257
+ function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
258
+ return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
259
+ }
260
+
261
+ // A minimal, VALID `bongos init` spec (init.validateSpec greenfield) from the
262
+ // provisioning row + the owner's github login. The scaffold produces a bootable,
263
+ // NEUTRAL instance; the owner brands + scopes it later via init's kickoff tasks.
264
+ function instanceInitSpec(inst, { ownerLogin } = {}) {
265
+ const repo = parseTargetRef(inst.target_ref);
266
+ return {
267
+ identity: { productName: inst.slug },
268
+ repo: repo || { owner: ownerLogin || 'owner', name: inst.slug },
269
+ firstAdmin: ownerLogin || (repo && repo.owner) || 'owner',
270
+ currency: { label: 'credits' },
271
+ domains: { publicOrigin: inst.domain ? `https://${inst.domain}` : `http://localhost:${inst.port || 3000}` },
272
+ modules: [],
273
+ version: { id: 'V1', name: 'V1', done_when: 'A task goes claim → grade → ship.' },
274
+ };
275
+ }
276
+
277
+ // The .gitignore a scaffolded instance repo carries: keep node_modules + the local
278
+ // dist/ + secrets OUT of the commit, while the vendored core tarball (vendor/*.tgz)
279
+ // and package-lock.json stay IN — `npm ci` on deploy resolves the pinned core from
280
+ // the committed tarball (ADR 0108), so they MUST be tracked.
281
+ const INSTANCE_GITIGNORE = ['node_modules/', 'dist/', '.env', '.env.local', '*.log', ''].join('\n');
282
+
283
+ // Clone the (empty) target_ref → standaloneRoot → materialize config/ + .claude/ + a
284
+ // file:-pinned @cloudbongos/core + lockfile (via `bongos init --vendor-core`, task 2053)
285
+ // → commit + push. Idempotent (skips once the repo carries a package.json) and dry-run
286
+ // safe (every mutation goes through the injected exec/writeFile). ADR 0108 / 0125.
287
+ async function scaffoldStandaloneRepo(inst, deps) {
288
+ // The scaffold BUILDS + pushes the instance's repo (git clone, package-core,
289
+ // `bongos init`, git push) — control-plane work that needs the packaging tools +
290
+ // the GitHub deploy key, so it runs LOCALLY even when box-mutating steps go
291
+ // remote (task 2065). Falls back to exec/writeFile when a caller passes no
292
+ // control executor (then all are the same local executor — unchanged behavior).
293
+ const { db, log } = deps;
294
+ const exec = deps.controlExec || deps.exec;
295
+ const writeFile = deps.controlWriteFile || deps.writeFile;
296
+ const root = standaloneRoot(inst);
297
+ const repo = parseTargetRef(inst.target_ref);
298
+ if (!repo) {
299
+ throw new Error(`standalone instance ${inst.slug}: target_ref "${inst.target_ref || '(unset)'}" is not an owner/repo — set it (e.g. <owner>/${inst.slug}) so the scaffold knows which repo to clone + push.`);
300
+ }
301
+ // Idempotent: a completed scaffold leaves package.json (the file:-pinned core). Skip.
302
+ if (fs.existsSync(path.join(root, 'package.json'))) {
303
+ log(` [scaffold] ${root} already scaffolded (package.json present) — skipping`);
304
+ return { already: true };
305
+ }
306
+ const coreV = coreVersionSafe();
307
+ if (!coreV) {
308
+ throw new Error('cannot resolve the core version (src/module-api CORE_VERSION) — run the scaffold from the control-plane checkout.');
309
+ }
310
+ const remote = instanceRepoRemote(repo);
311
+ log(` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
312
+
313
+ // Owner login = the founding firstAdmin. Best-effort; the spec falls back to the repo owner.
314
+ let ownerLogin = null;
315
+ if (inst.owner_builder_id && db) {
316
+ try {
317
+ const r = await db.query('SELECT github_login FROM builders WHERE id = $1', [inst.owner_builder_id]);
318
+ ownerLogin = r && r.rows && r.rows[0] ? r.rows[0].github_login : null;
319
+ } catch { /* fall back to the repo owner */ }
320
+ }
321
+
322
+ // 1. clone the (possibly empty) repo, then force the initial branch to `main` (an empty
323
+ // clone leaves an unborn HEAD). A re-run after a partial clone reuses the existing dir.
324
+ if (!fs.existsSync(path.join(root, '.git'))) {
325
+ exec(`mkdir -p ${path.posix.dirname(root)}`);
326
+ exec(`git clone ${remote} ${root}`);
327
+ }
328
+ exec(`git -C ${root} checkout -B main`);
329
+
330
+ // 2. build the installable core tarball from THIS control-plane checkout (the core source).
331
+ exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/cloudbongos-core-<v>.tgz
332
+ const tgz = path.join(REPO_ROOT, 'dist', `cloudbongos-core-${coreV}.tgz`);
333
+
334
+ // 3. materialize config/ + .claude/ + a file:-pinned package.json + lockfile via `bongos init`
335
+ // (--vendor-core, task 2053). --no-seed: the instance's own DB is seeded by migrate, not here.
336
+ const spec = instanceInitSpec(inst, { ownerLogin });
337
+ const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
338
+ writeFile(specPath, JSON.stringify(spec, null, 2));
339
+ exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed --force --vendor-core ${tgz}`);
340
+
341
+ // 4. commit + push the scaffold to the instance's own main (keep node_modules out).
342
+ writeFile(path.join(root, '.gitignore'), INSTANCE_GITIGNORE);
343
+ exec(`git -C ${root} config user.name "Cloud Bongos"`);
344
+ exec(`git -C ${root} config user.email "bot@cloudbongos.com"`);
345
+ exec(`git -C ${root} add -A`);
346
+ exec(`git -C ${root} commit -m "chore: scaffold ${inst.slug} on a pinned @cloudbongos/core ${coreV} (provision.js, ADR 0108)"`, { allowFail: true });
347
+ exec(`git -C ${root} push -u origin main`);
348
+ log(` [scaffold] pushed initial scaffold to ${repo.owner}/${repo.name}@main`);
349
+ return { ok: true, scaffolded: true };
350
+ }
351
+
352
+ // The per-instance systemd unit (the infra/cloudbongos.service shape): one Node process
353
+ // bound to the instance's port + DB + brand, secrets from its own env file. SHAPE-AWARE:
354
+ // a STANDALONE instance runs the CORE PACKAGE's platform-server from its OWN repo dir
355
+ // (WorkingDirectory=standaloneRoot → resolveInstanceRoot() resolves to the instance via
356
+ // cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
357
+ function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
358
+ const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
359
+ const standalone = inst.hosting_shape === 'standalone';
360
+ const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
361
+ const execStart = standalone
362
+ ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
363
+ : `/usr/bin/node src/platform-server.js`;
364
+ return `[Unit]
365
+ Description=Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0111)
366
+ After=network-online.target postgresql.service
367
+ Wants=network-online.target
368
+ Requires=postgresql.service
369
+
370
+ [Service]
371
+ Type=simple
372
+ User=lars
373
+ Group=lars
374
+ WorkingDirectory=${workDir}
375
+ EnvironmentFile=${envFile}
376
+ Environment=PGDATABASE=${dbName(inst)}
377
+ Environment=PORT=${inst.port}
378
+ ExecStart=${execStart}
379
+ Restart=always
380
+ RestartSec=3
381
+ StandardOutput=journal
382
+ StandardError=journal
383
+ NoNewPrivileges=true
384
+
385
+ [Install]
386
+ WantedBy=multi-user.target
387
+ `;
388
+ }
389
+ function serviceUnitPath(inst) { return `/etc/systemd/system/${inst.slug}.service`; }
390
+
391
+ // The branding pack a provisioned CO-TENANT instance loads: the shared, committed
392
+ // neutral starter (the co-tenant model shares ONE checkout, differentiated only by
393
+ // env — src/branding.js's GDS_BRANDING_FILE knob). Per-instance identity/domains
394
+ // therefore ride ENV OVERRIDES, not a divergent pack. Kept as a constant so the
395
+ // GDS_BRANDING_FILE line below and the env-var PREFIX (which must equal this pack's
396
+ // envPrefix, or src/branding.js's ENV_OVERRIDES never fire) can't drift apart.
397
+ const PROVISIONED_BRANDING_FILE = 'config/branding.neutral.json';
398
+
399
+ // The env-var prefix a provisioned instance actually RESOLVES — i.e. the envPrefix
400
+ // of the pack it loads (PROVISIONED_BRANDING_FILE). src/instance-config.resolveEnv
401
+ // reads <envPrefix>_<SUFFIX>, and src/branding.resolveBranding applies
402
+ // <envPrefix>_{PUBLIC,BUILDERS,…}_ORIGIN overrides — so a web.env var only takes
403
+ // effect under THIS prefix. Read from the pack (fail-soft to the vanilla default)
404
+ // so it stays in lockstep instead of a magic string. (The prior slug-based prefix
405
+ // was a latent bug: it never matched the neutral pack's CLOUDBONGOS envPrefix, so
406
+ // the OAuth creds were silently unreadable — task 1972.)
407
+ function provisionedEnvPrefix() {
408
+ try {
409
+ const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, PROVISIONED_BRANDING_FILE), 'utf8'));
410
+ return pack.envPrefix || 'CLOUDBONGOS';
411
+ } catch { return 'CLOUDBONGOS'; }
412
+ }
413
+
414
+ // The per-instance secrets file body (the standup runbook's /etc/<inst>/web.env).
415
+ // The OAuth client id/secret are GUIDED/manual inputs (no GitHub create-API) — the
416
+ // runner writes placeholders the owner fills, unless provided via the intent later.
417
+ // A domain (bring-your-own or provisioned) drives the single-host origin overrides:
418
+ // with them set, src/branding.js resolves the instance's REAL buildersOrigin, so the
419
+ // core's single-host router (isBuildersHost === buildersOrigin host, task 1971) serves
420
+ // the hall on this host + OAuth uses the real callback origin — without them the pack
421
+ // stays at neutral localhost and the hall never loads (task 1972). No domain yet
422
+ // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
423
+ function webEnvBody(inst, { clientId = '', clientSecret = '' } = {}) {
424
+ const prefix = provisionedEnvPrefix();
425
+ const origin = inst.domain ? `https://${inst.domain}` : '';
426
+ const originLines = origin
427
+ ? `# Single-host origins (task 1972): the hall serves on THIS host + OAuth/redirects
428
+ # use the real address. Resolved by src/branding.js ENV_OVERRIDES under the pack's
429
+ # ${prefix} envPrefix. A single-host instance has no dedicated builders./status.
430
+ # subdomain, so all four point at the one host.
431
+ ${prefix}_PUBLIC_ORIGIN=${origin}
432
+ ${prefix}_BUILDERS_ORIGIN=${origin}
433
+ ${prefix}_STATUS_ORIGIN=${origin}
434
+ ${prefix}_OAUTH_ORIGIN=${origin}
435
+ `
436
+ : `# No domain yet (bring-your-own-domain pending, ADR 0111 §4) — origins stay
437
+ # neutral (localhost); add ${prefix}_{PUBLIC,BUILDERS,OAUTH}_ORIGIN once a domain
438
+ # is set, or the hall won't load on the instance's host.
439
+ `;
440
+ // The OAuth app creds are filled by ONE of two paths (task 2080): the GitHub App
441
+ // Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
442
+ // file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
443
+ // the first provision write they are blank placeholders the flow fills later.
444
+ const credComment = clientId || clientSecret
445
+ ? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
446
+ # GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
447
+ : `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
448
+ # one-click GitHub App Manifest flow (ADR 0133), or manually (oauth-secret.js place):`;
449
+ return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
450
+ ${credComment}
451
+ ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
452
+ ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
453
+ GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
454
+ ${originLines}PORT=${inst.port}
455
+ PGDATABASE=${dbName(inst)}
456
+ `;
457
+ }
458
+ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
459
+ return path.posix.join(etcBase, inst.slug, 'web.env');
460
+ }
461
+
462
+ // The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
463
+ // registrable domains, which for the common case is the last two DNS labels
464
+ // (mercury.example.com → example.com; apex example.com → example.com). Multi-part
465
+ // public suffixes (foo.co.uk) are NOT special-cased here — set CLOUDFLARE_ZONE in
466
+ // the control-plane box.env to override (box-dns-cloudflare.sh honours it). PURE.
467
+ function zoneForDomain(domain) {
468
+ const d = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
469
+ if (!d) return '';
470
+ const labels = d.split('.').filter(Boolean);
471
+ return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
472
+ }
473
+
474
+ // The env a DNS UPSERT invocation of infra/box-dns-cloudflare.sh needs for a
475
+ // PROVISIONED host: the hostname, the target public IP (the A-record content), the
476
+ // zone the record lives in, and whether Cloudflare proxies it.
477
+ // • proxied=false (default) is LOAD-BEARING for a CO-TENANT/dedicated host: it uses
478
+ // Caddy on-demand ACME (Let's Encrypt), whose challenge must reach Caddy directly —
479
+ // Cloudflare proxy ON would intercept :80/:443 at the edge and the challenge would
480
+ // silently fail (ADR 0003 / docs/recipes/ops-gotchas.md). Do NOT proxy those.
481
+ // • proxied=true for a STANDALONE host (task 2077): it runs control-plane-local under
482
+ // the *.cloudbongos.com wildcard Cloudflare Origin cert, so on-demand ACME never
483
+ // fires (a matching cert already exists) and proxied=false would serve that origin
484
+ // cert — untrusted to direct clients. Proxying lets the CF edge present a public cert
485
+ // (origin cert valid behind Full-strict) AND hides the control-plane IP.
486
+ // An explicit `zone` wins over the derived one (for a multi-part-suffix domain). PURE.
487
+ function dnsUpsertEnv(inst, { publicIp, zone, proxied = false } = {}) {
488
+ return {
489
+ BOX_HOSTNAME: inst.domain,
490
+ BOX_IP: publicIp,
491
+ CLOUDFLARE_ZONE: zone || zoneForDomain(inst.domain),
492
+ BOX_PROXIED: proxied ? '1' : '0',
493
+ };
494
+ }
495
+
496
+ // ── DNS token-scope PREFLIGHT (task 1980) ────────────────────────────────────
497
+ // Onboarding a NEW project on a domain the control-plane's Cloudflare token does NOT
498
+ // cover fails LATE: the DNS UPSERT (step 5) errors mid-provision (the Emersonian
499
+ // standup — the token only covered the platform's original zone, so the new project's
500
+ // record couldn't be created until it was widened MID-FLOW). This preflight detects
501
+ // "the configured token can't reach the project's zone" UP FRONT, before the
502
+ // connect-address step, and GUIDES the fix — instead of a half-done provision. It is
503
+ // READ-ONLY (a CF `GET /zones?name=<zone>`); it never mutates DNS.
504
+ const CF_API_BASE = process.env.CLOUDFLARE_API_BASE || 'https://api.cloudflare.com/client/v4';
505
+
506
+ // PURE: given the outcome of `GET /zones?name=<zone>` (HTTP status + parsed JSON) and
507
+ // the target zone, decide whether the token can MANAGE that zone, with actionable
508
+ // guidance when it can't. Exported + unit-tested with no network.
509
+ function classifyZoneScope({ status, body, zone }) {
510
+ const z = String(zone || '');
511
+ if (status === 401 || status === 403) {
512
+ return { reachable: false, reason: `Cloudflare rejected the token (HTTP ${status})`,
513
+ guidance: `The DNS token is invalid or lacks Zone:Read. Set CLOUDFLARE_API_TOKEN in the control-plane box.env to a token scoped to include ${z} (Zone:DNS:Edit + Zone:Read).` };
514
+ }
515
+ if (status !== 200 || !body || body.success !== true) {
516
+ const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
517
+ return { reachable: false, reason: `Cloudflare zone lookup failed (HTTP ${status}${errs ? `: ${errs}` : ''})`,
518
+ guidance: `Check CLOUDFLARE_API_TOKEN + network from the control plane, then retry.` };
519
+ }
520
+ const result = Array.isArray(body.result) ? body.result : [];
521
+ const match = result.find((r) => r && r.name === z);
522
+ if (!match) {
523
+ return { reachable: false, reason: `the token authenticated, but zone "${z}" is not in its scope`,
524
+ guidance: `Widen the Cloudflare API token to include ${z}: CF dashboard → My Profile → API Tokens → edit the token → Zone Resources → Include → ${z} (or use an account-scoped token). If ${z} is a multi-part public suffix (e.g. foo.co.uk), set CLOUDFLARE_ZONE explicitly in the control-plane box.env.` };
525
+ }
526
+ return { reachable: true, reason: `token can manage zone "${z}" (id ${match.id})`, zoneId: match.id };
527
+ }
528
+
529
+ // Query Cloudflare for the domain's zone with the configured token. Injectable
530
+ // `token`/`fetchImpl` for tests. Returns { zone, reachable, reason, guidance[, zoneId] }.
531
+ // A missing token/fetch/domain is reported as not-reachable-with-guidance — it never
532
+ // throws for a config gap (the caller decides whether to hard-fail).
533
+ async function checkDnsTokenScope(domain, { token = process.env.CLOUDFLARE_API_TOKEN, zoneOverride = process.env.CLOUDFLARE_ZONE, fetchImpl } = {}) {
534
+ const zone = zoneOverride || zoneForDomain(domain);
535
+ const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
536
+ if (!zone) return { zone, reachable: false, reason: 'no domain given', guidance: 'provide a domain before the address step' };
537
+ if (!token) return { zone, reachable: false, reason: 'no CLOUDFLARE_API_TOKEN configured', guidance: `Set CLOUDFLARE_API_TOKEN (Zone:DNS:Edit on ${zone}) in the control-plane box.env before provisioning DNS for ${domain}.` };
538
+ if (!doFetch) return { zone, reachable: false, reason: 'no fetch available', guidance: 'run on a Node with global fetch, or inject fetchImpl' };
539
+ let status = 0; let bodyJson = null;
540
+ try {
541
+ const res = await doFetch(`${CF_API_BASE}/zones?name=${encodeURIComponent(zone)}`, {
542
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
543
+ });
544
+ status = res.status;
545
+ bodyJson = await res.json().catch(() => null);
546
+ } catch (e) {
547
+ return { zone, reachable: false, reason: `Cloudflare request failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
548
+ }
549
+ return { zone, ...classifyZoneScope({ status, body: bodyJson, zone }) };
550
+ }
551
+
552
+ // A Caddy reverse-proxy site block for the instance's domain → its local port.
553
+ // TLS = on-demand ACME (P4, ADR 0111 §4): Caddy fetches a Let's Encrypt cert for
554
+ // this host LAZILY, on the first TLS handshake — no manual Cloudflare Origin-Cert
555
+ // console step (the least-automated leg). It is GATED by the global
556
+ // `on_demand_tls { ask … }` allow-list in infra/Caddyfile (→ /api/bongos/
557
+ // provisioning/tls-check), so Caddy only mints a cert for a host this module
558
+ // actually provisioned — never an arbitrary SNI. The A record is proxied=false
559
+ // (dnsUpsertEnv) so the challenge reaches Caddy. No domain → a comment
560
+ // (bring-your-own-domain is deferred per-instance, ADR 0111 §4).
561
+ function caddyBlock(inst) {
562
+ if (!inst.domain) {
563
+ return `# ${inst.slug}: no domain yet (bring-your-own-domain pending) — reachable on 127.0.0.1:${inst.port}\n`;
564
+ }
565
+ return `${inst.domain} {
566
+ tls {
567
+ on_demand
568
+ }
569
+ reverse_proxy 127.0.0.1:${inst.port}
570
+ }
571
+ `;
572
+ }
573
+ function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
574
+ return path.posix.join(snippetDir, `${inst.slug}.caddy`);
575
+ }
576
+ function healthzCmd(inst) {
577
+ return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // The injectable executor. dry-run → log-only; --apply → child_process.
582
+ // ---------------------------------------------------------------------------
583
+ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
584
+ return function run(cmd, { allowFail = false, cwd, env } = {}) {
585
+ // Extra env is passed to the child (not interpolated into the command), so
586
+ // values like a hostname/IP never need shell quoting. It's shown in the dry-run
587
+ // plan for transparency — DNS env (BOX_HOSTNAME/BOX_IP/CLOUDFLARE_ZONE) is not
588
+ // secret (the token stays in the hook's own EnvironmentFile, out of `env`).
589
+ const envPrefix = env ? `${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')} ` : '';
590
+ if (remoteHost) {
591
+ // Remote step (task 2065): run it on the co-hosting box over SSH. The cwd +
592
+ // env + command are composed into a small script fed to the remote shell via
593
+ // STDIN (`ssh … bash -s`), so nothing needs shell-quoting on the way over
594
+ // (the #1 remote-exec footgun). cwd defaults to the remote checkout so a
595
+ // relative `./scripts/migrate.sh` resolves there. env goes INTO the script
596
+ // (a local child env wouldn't cross the ssh hop).
597
+ const wd = cwd || remoteCwd;
598
+ const script = `${wd ? `cd ${wd} && ` : ''}${envPrefix}${cmd}`;
599
+ const sshCmd = `ssh ${sshOpts} ${remoteHost} bash -s`;
600
+ const shown = `[ssh ${remoteHost}] ${script}`;
601
+ if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
602
+ try {
603
+ const stdout = cp.execSync(sshCmd, { input: script, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }).toString();
604
+ log(` ✓ ${shown}`);
605
+ return { ok: true, stdout };
606
+ } catch (e) {
607
+ if (allowFail) { log(` ~ ${shown} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
608
+ log(` ✖ ${shown}`);
609
+ throw e;
610
+ }
611
+ }
612
+ // Local step (control plane / today's behavior). cwd defaults to REPO_ROOT.
613
+ const shown = `${envPrefix}${cmd}`;
614
+ if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
615
+ try {
616
+ const stdout = cp.execSync(cmd, { cwd: cwd || REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env }).toString();
617
+ log(` ✓ ${cmd}`);
618
+ return { ok: true, stdout };
619
+ } catch (e) {
620
+ if (allowFail) { log(` ~ ${cmd} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
621
+ log(` ✖ ${cmd}`);
622
+ throw e;
623
+ }
624
+ };
625
+ }
626
+ // A file write that respects dry-run (log-only) and applies chmod when asked.
627
+ // When privileged AND the call opts in with { sudo: true } (the /etc destinations
628
+ // the runner's non-root user can't write directly), the body is staged in a temp
629
+ // file (lars-writable) then atomically placed with `sudo install -D -m <mode>`
630
+ // (`-D` creates the parent dir, e.g. /etc/<slug>/). Otherwise a direct fs write
631
+ // (unchanged). `exec` is injected so the install goes through the SAME dry-run/apply
632
+ // + logging path as every other command (task 2066).
633
+ function makeWriteFile({ apply, privileged = false, exec, log = console.log, remoteHost = null }) {
634
+ return function writeFile(p, body, { mode, sudo = false } = {}) {
635
+ if (remoteHost) {
636
+ // Remote file placement (task 2065): base64 the body, decode it ON the
637
+ // co-hosting box, and `tee` it into place — sudo-escalated when the
638
+ // destination (/etc/<slug>/…) needs root and we're privileged. base64 keeps
639
+ // arbitrary bytes/newlines out of the shell. It rides the box `exec` (which
640
+ // ssh's it), so it shares the same dry-run + logging + ssh path.
641
+ const dir = path.posix.dirname(p);
642
+ const sp = (sudo && privileged) ? 'sudo ' : '';
643
+ const b64 = Buffer.from(body, 'utf8').toString('base64');
644
+ const steps = [
645
+ `${sp}mkdir -p ${dir}`,
646
+ `printf %s '${b64}' | base64 -d | ${sp}tee ${p} >/dev/null`,
647
+ ];
648
+ if (mode) steps.push(`${sp}chmod ${mode.toString(8)} ${p}`);
649
+ const cmd = steps.join(' && ');
650
+ if (!apply) {
651
+ log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) on ${remoteHost}`);
652
+ if (exec) exec(cmd);
653
+ return { dryRun: true };
654
+ }
655
+ exec(cmd);
656
+ log(` ✓ wrote ${p} (on ${remoteHost})`);
657
+ return { ok: true };
658
+ }
659
+ if (sudo && privileged) {
660
+ // lars can't write under /etc; stage in a temp file then place it as root.
661
+ const tmp = path.join(os.tmpdir(), `prov-${path.basename(p)}-${process.pid}`);
662
+ const installCmd = `sudo install -D ${mode ? `-m ${mode.toString(8)} ` : ''}${tmp} ${p}`;
663
+ if (!apply) {
664
+ log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) via temp + sudo install`);
665
+ if (exec) exec(installCmd);
666
+ return { dryRun: true };
667
+ }
668
+ fs.writeFileSync(tmp, body);
669
+ try { exec(installCmd); } finally { fs.rmSync(tmp, { force: true }); }
670
+ log(` ✓ wrote ${p} (via sudo install)`);
671
+ return { ok: true };
672
+ }
673
+ if (!apply) { log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''})`); return { dryRun: true }; }
674
+ fs.mkdirSync(path.dirname(p), { recursive: true });
675
+ fs.writeFileSync(p, body, mode ? { mode } : undefined);
676
+ if (mode) fs.chmodSync(p, mode);
677
+ log(` ✓ wrote ${p}`);
678
+ return { ok: true };
679
+ };
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Port allocation for a co-tenant instance: the lowest free port ≥ portBase not
684
+ // already taken by another instance row or a reserved port. PURE given the taken set.
685
+ // ---------------------------------------------------------------------------
686
+ function nextFreePort(takenPorts, { portBase = CONFIG.portBase, reserved = CONFIG.reservedPorts } = {}) {
687
+ const taken = new Set([...(takenPorts || []), ...reserved].map(Number));
688
+ let p = portBase;
689
+ while (taken.has(p)) p += 1;
690
+ return p;
691
+ }
692
+ // Ports held by a LISTENING socket on the box the instance will bind on. A
693
+ // co-tenant/standalone instance binds 127.0.0.1:<port>, so ANY process already
694
+ // listening there — not just another provisioned instance or a CONFIG reserved
695
+ // port — causes an EADDRINUSE crash-loop. Snapshot `ss` and return every listening
696
+ // port so allocatePort can exclude them all → robust to any co-located service
697
+ // (e.g. Grafana on the dedicated control plane, task 2070). `allowFail` degrades to
698
+ // the prior DB-only behavior if `ss` is unavailable rather than blocking provisioning.
699
+ function boundLocalPorts(exec) {
700
+ if (typeof exec !== 'function') return [];
701
+ let res;
702
+ try { res = exec('ss -ltnH 2>/dev/null || ss -ltn 2>/dev/null', { allowFail: true }); }
703
+ catch { return []; }
704
+ const ports = [];
705
+ for (const line of String((res && res.stdout) || '').split('\n')) {
706
+ const cols = line.trim().split(/\s+/);
707
+ if (cols.length < 4) continue; // skip header / blank lines
708
+ const m = /:(\d+)$/.exec(cols[3]); // Local Address:Port → 127.0.0.1:3003, *:80, [::1]:3003
709
+ if (m) ports.push(Number(m[1]));
710
+ }
711
+ return ports;
712
+ }
713
+ // Impure: reads the instance table AND (via the injected exec) the box's live
714
+ // listening ports, so a port squatted by a NON-instance co-tenant is never handed
715
+ // out. `exec` is the same executor provisionInstance uses, so for a remote co-tenant
716
+ // it snapshots the remote box (task 2065) — where the instance actually binds.
717
+ async function allocatePort(db, provisioning, { exec } = {}) {
718
+ const rows = await db.query(`SELECT port FROM provisioning_instances WHERE port IS NOT NULL`);
719
+ const dbPorts = rows.rows.map((r) => r.port);
720
+ return nextFreePort([...dbPorts, ...boundLocalPorts(exec)]);
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // Compose + run the standup runbook for ONE instance (idempotent, dry-run-safe).
725
+ // deps: { db, provisioning, doApi, exec, writeFile, log, apply, privileged }
726
+ // ---------------------------------------------------------------------------
727
+ async function provisionInstance(inst, deps) {
728
+ const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
729
+ // The token step (Cloudflare DNS hook) runs on the control plane where the token
730
+ // lives — NEVER on the remote box (task 2065). Falls back to `exec` when a caller
731
+ // (e.g. a test) passes no controlExec — then both are the same local executor.
732
+ const controlExec = deps.controlExec || exec;
733
+ // Standalone instances run on the CONTROL PLANE (task 2074): scaffoldStandaloneRepo
734
+ // created their OWN repo dir here via controlExec, so the box-mutating steps below
735
+ // (code sync, migrate, /etc writes, systemd, Caddy, healthz) must run on the SAME
736
+ // box — NOT the remote co-hosting box that PROVISION_REMOTE_HOST routes co-tenant
737
+ // steps to (that box never had the dir; ADR 0130's remote-exec was co-tenant-only).
738
+ // So a standalone uses the control-plane executors; co-tenant + dedicated are
739
+ // unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
740
+ // set, controlExec === exec anyway, so this is a no-op for the all-local default.
741
+ const standalone = inst.hosting_shape === 'standalone';
742
+ const boxExec = standalone ? controlExec : exec;
743
+ const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
744
+ const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
745
+ log(`provision ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
746
+
747
+ // Idempotency: an already-active instance is a no-op success (re-drained intent).
748
+ if (inst.status === 'active') { log(' already active — nothing to do'); return { ok: true, already: true }; }
749
+
750
+ // ── preflight: DNS token scope (task 1980) ─────────────────────────────────
751
+ // BEFORE committing to the address (allocating a port / creating a droplet / the
752
+ // step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
753
+ // the domain's zone. Turns the Emersonian standup's confusing mid-flow step-5 DNS
754
+ // failure into an up-front, actionable stop with no half-done provision. Read-only.
755
+ if (inst.domain && CONFIG.dnsHook) {
756
+ if (apply) {
757
+ // Injectable so the composition stays hermetic in tests (deps.checkDnsTokenScope
758
+ // stubs the CF call); production passes the real checker (network read-only).
759
+ const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
760
+ if (!scope.reachable) {
761
+ throw new Error(`DNS preflight failed for ${inst.domain}: ${scope.reason}. ${scope.guidance}`);
762
+ }
763
+ log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
764
+ } else {
765
+ log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
766
+ }
767
+ }
768
+
769
+ if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
770
+ const patch = {};
771
+
772
+ // ── step 1: server ───────────────────────────────────────────────────────
773
+ // The A-record target for step 5's DNS UPSERT:
774
+ // • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
775
+ // is the co-hosting box, ADR 0130).
776
+ // • standalone → the CONTROL PLANE's own IP (task 2074): it runs control-plane-
777
+ // local, so its domain must point HERE, not at the co-hosting box.
778
+ // PROVISION_CONTROL_PLANE_IP when remote-exec splits the two boxes;
779
+ // else PROVISION_PUBLIC_IP (all-local: one and the same machine).
780
+ // Remote-exec set but no control-plane IP configured → null, which
781
+ // makes step 5 fail loud rather than point the domain at the wrong box.
782
+ // • dedicated → the droplet's own IP (captured once it settles, below).
783
+ let recordIp;
784
+ if (inst.hosting_shape === 'dedicated') recordIp = null;
785
+ else if (standalone) recordIp = CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp);
786
+ else recordIp = CONFIG.publicIp;
787
+ if (inst.hosting_shape === 'dedicated') {
788
+ // Dedicated droplet: create it + settle its public IP via the shared DO client
789
+ // (do-api, dry-run-default). The on-droplet app standup rides cloud-init
790
+ // user-data built from the SAME step commands below (mirrors box.js). For the
791
+ // dry-run plan we surface the intent; --apply creates the droplet.
792
+ const doApi = deps.doApi;
793
+ log(' [server] dedicated droplet');
794
+ if (apply && doApi) {
795
+ const { droplet } = await doApi.createDroplet({
796
+ name: `cb-${inst.slug}`, region: CONFIG.region, size: inst.tier || 's-2vcpu-4gb',
797
+ image: CONFIG.baseImage, tags: [CONFIG.tag], sshKeys: CONFIG.sshKeyIds,
798
+ userData: dedicatedUserData(inst),
799
+ });
800
+ const settled = await doApi.settleDroplet(droplet.id);
801
+ patch.error_note = null;
802
+ recordIp = settled.ip || null; // the droplet's own IP is this instance's A-record target
803
+ log(` droplet ${droplet.id} @ ${settled.ip || '(pending)'} — app standup rides cloud-init`);
804
+ } else {
805
+ log(' would createDroplet + settle IP, baking cloud-init that runs the standup steps');
806
+ }
807
+ } else {
808
+ // Co-tenant OR standalone: a process bound to an allocated port on the box it runs
809
+ // on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
810
+ // remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
811
+ // dir with a pinned @cloudbongos/core (synced in step 2) on the CONTROL PLANE.
812
+ // Allocate against the ports of whichever box it runs on (boxExec: remote for a
813
+ // co-tenant, local for a standalone — task 2074), then run the rest there.
814
+ if (!inst.port) {
815
+ const port = await allocatePort(db, provisioning, { exec: boxExec });
816
+ patch.port = port;
817
+ inst = { ...inst, port };
818
+ log(` [server] ${inst.hosting_shape} — allocated port ${port}`);
819
+ } else {
820
+ log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
821
+ }
822
+ // Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
823
+ // materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
824
+ // (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
825
+ // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
826
+ if (inst.hosting_shape === 'standalone') {
827
+ await scaffoldStandaloneRepo(inst, deps);
828
+ }
829
+ }
830
+ if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
831
+
832
+ // The remaining steps run on the instance's box for co-tenant + standalone (boxExec/
833
+ // boxWriteFile — remote for co-tenant under remote-exec, control-plane-local for
834
+ // standalone; task 2074); for dedicated they are baked into cloud-init (above) and
835
+ // re-asserted here as idempotent no-ops on the box.
836
+ const local = inst.hosting_shape !== 'dedicated';
837
+
838
+ // ── step 2: code sync (standalone) + database ─────────────────────────────
839
+ if (local && standalone) {
840
+ // Standalone (ADR 0108 / deploy-instance.sh steps 1-2): sync the instance's own
841
+ // repo + install the pinned @cloudbongos/core, so the migrate + service below run
842
+ // from the INSTANCE root, not this control-plane checkout. cwd = standaloneRoot.
843
+ // boxExec = the control plane (where scaffoldStandaloneRepo just created the dir).
844
+ const root = standaloneRoot(inst);
845
+ log(' [code] standalone — pull instance repo + npm ci (pinned @cloudbongos/core)');
846
+ boxExec(standalonePullCmd(), { cwd: root });
847
+ boxExec(standaloneInstallCmd(), { cwd: root });
848
+ }
849
+ log(' [db] create + GDS-only migrate');
850
+ if (local) {
851
+ boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
852
+ if (standalone) boxExec(standaloneMigrateCmd(inst, { privileged }), { cwd: standaloneRoot(inst) }); // instance-root migrate
853
+ else boxExec(migrateCmd(inst, { privileged }));
854
+ } else { log(' (runs via cloud-init on the droplet)'); }
855
+
856
+ // ── step 3: config + per-instance secrets ─────────────────────────────────
857
+ log(' [config] per-instance web.env');
858
+ if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst), { mode: 0o600, sudo: true });
859
+ else log(' (baked into cloud-init)');
860
+
861
+ // ── step 4: systemd unit + enable ─────────────────────────────────────────
862
+ log(' [service] install + enable systemd unit');
863
+ if (local) {
864
+ boxWriteFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
865
+ boxExec(`${sudoP}systemctl daemon-reload`);
866
+ boxExec(`${sudoP}systemctl enable --now ${inst.slug}`);
867
+ } else { log(' (unit baked into cloud-init)'); }
868
+
869
+ // ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
870
+ // Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
871
+ // record pointing the domain at its target IP (recordIp), in the domain's own
872
+ // zone, proxied=false so Caddy's on-demand ACME challenge (step 6) reaches the
873
+ // origin (dnsUpsertEnv). It runs on the CONTROL PLANE for BOTH shapes: the hook
874
+ // needs the Cloudflare token, which never leaves the control plane, so a dedicated
875
+ // instance's DNS is upserted from here (not baked into the droplet's cloud-init).
876
+ // Unlike P3's best-effort allowFail, a DNS FAILURE now SURFACES (the task-1180
877
+ // lesson: never report provision success while the A record silently didn't land).
878
+ if (inst.domain && CONFIG.dnsHook) {
879
+ if (!recordIp) {
880
+ const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
881
+ if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env, or the droplet must settle an IP (dedicated)`);
882
+ log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
883
+ } else {
884
+ // Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
885
+ // cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant +
886
+ // dedicated stay proxied=false so their on-demand ACME challenge reaches the origin.
887
+ const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
888
+ log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
889
+ // controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
890
+ // only on the control plane — it must never run on the (token-free) co-hosting
891
+ // box even when the box-mutating steps above went there over SSH (task 2065).
892
+ controlExec(CONFIG.dnsHook, { env: dnsEnv });
893
+ }
894
+ } else {
895
+ log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
896
+ }
897
+
898
+ // ── step 6: Caddy per-instance site snippet + reload ──────────────────────
899
+ // Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
900
+ // injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
901
+ // The main Caddyfile carries a one-time `import <snippetDir>/*.caddy` (operator
902
+ // setup). The on-demand-ACME TLS hardening of this block is task P4 (#1945).
903
+ log(' [caddy] write site snippet + reload');
904
+ if (local) {
905
+ boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
906
+ boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
907
+ } else { log(' (Caddy on the droplet via cloud-init)'); }
908
+
909
+ // ── step 7: healthz ───────────────────────────────────────────────────────
910
+ log(' [healthz] verify the instance answers');
911
+ if (local) boxExec(healthzCmd(inst), { allowFail: true });
912
+ else log(' (verified post-boot)');
913
+
914
+ // Mark active.
915
+ if (apply) {
916
+ await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
917
+ await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active', detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})`, actor: 'runner' });
918
+ }
919
+ log(` ✓ ${inst.slug} provisioned`);
920
+ return { ok: true, port: inst.port };
921
+ }
922
+
923
+ // The cloud-init user-data for a dedicated droplet: run the co-tenant standup
924
+ // steps on first boot. Kept small (well under DO's 64 KiB cap). Built from the
925
+ // SAME pure command generators so local + dedicated never diverge.
926
+ function dedicatedUserData(inst) {
927
+ const i = { ...inst, db_name: inst.db_name || inst.slug };
928
+ return `#cloud-config
929
+ runcmd:
930
+ - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
931
+ - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
932
+ - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug}" ]
933
+ `;
934
+ }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // Teardown one instance (reverse the runbook; idempotent).
938
+ // ---------------------------------------------------------------------------
939
+ async function teardownInstance(inst, deps) {
940
+ const { db, provisioning, exec, log, apply, privileged } = deps;
941
+ // Tear down on the box the instance actually RAN on: a standalone ran control-plane-
942
+ // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
943
+ // box, where it was never installed (that would allowFail-noop and leave the real
944
+ // control-plane unit running). Co-tenant + dedicated are unchanged.
945
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
946
+ const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
947
+ log(`teardown ${inst.slug} (status=${inst.status})`);
948
+ if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
949
+ if (inst.hosting_shape !== 'dedicated') {
950
+ boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
951
+ boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
952
+ boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
953
+ // DB + Caddy block are left for an explicit operator (data is precious; a
954
+ // teardown should not silently dropdb). We log the manual follow-ups.
955
+ log(` (left for the operator: dropdb ${dbName(inst)}, remove the Caddy block for ${inst.slug})`);
956
+ } else if (apply && deps.doApi && inst.droplet_id) {
957
+ await deps.doApi.deleteDroplet(inst.droplet_id);
958
+ }
959
+ if (apply) {
960
+ await provisioning.setInstanceStatus(db, inst.id, 'torn_down', { torn_down_at: new Date().toISOString() });
961
+ await provisioning.recordEvent(db, { instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'torn_down', detail: 'torn down by runner', actor: 'runner' });
962
+ }
963
+ log(` ✓ ${inst.slug} torn down`);
964
+ return { ok: true };
965
+ }
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // GitHub App Manifest conversion + cred placement (task 2080, ADR 0133).
969
+ // The web tier stored GitHub's temporary `code` on a provisioning_oauth_manifests
970
+ // row (status 'code_received'); THIS runner — the sole token-holder — exchanges it
971
+ // for the client creds and places them in the instance's web.env, then verifies
972
+ // /healthz. The secret is fetched here and written straight to disk: it never
973
+ // touches the web tier or the browser (ADR 0111 §2 / ADR 0016 trust boundary).
974
+ // ---------------------------------------------------------------------------
975
+
976
+ // Exchange GitHub's manifest `code` for the App's client creds. UNAUTHENTICATED by
977
+ // design — the single-use code IS the auth (GitHub docs). `fetchImpl` injectable for
978
+ // tests. Throws on a non-2xx or a response missing client_id/client_secret.
979
+ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAgent = MANIFEST_UA } = {}) {
980
+ if (!code) throw new Error('no manifest code to convert');
981
+ const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
982
+ method: 'POST',
983
+ headers: {
984
+ Accept: 'application/vnd.github+json',
985
+ 'User-Agent': userAgent,
986
+ 'X-GitHub-Api-Version': '2022-11-28',
987
+ },
988
+ });
989
+ const text = await res.text();
990
+ let data; try { data = JSON.parse(text); } catch { data = null; }
991
+ if (!res.ok) {
992
+ const msg = (data && (data.message || data.error)) || `HTTP ${res.status}`;
993
+ throw new Error(`GitHub manifest conversion failed: ${msg}`);
994
+ }
995
+ if (!data || !data.client_id || !data.client_secret) {
996
+ throw new Error('GitHub manifest conversion returned no client_id/client_secret');
997
+ }
998
+ return {
999
+ clientId: String(data.client_id),
1000
+ clientSecret: String(data.client_secret),
1001
+ appId: data.id != null ? Number(data.id) : null,
1002
+ appSlug: data.slug || null,
1003
+ };
1004
+ }
1005
+
1006
+ // Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
1007
+ // verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
1008
+ // or remote-cohost) and standalone (control-plane-local) are handled identically. A
1009
+ // dedicated droplet bakes web.env into cloud-init and has no post-hoc exec path here, so
1010
+ // it is refused with guidance. Returns { ok, appId, appSlug } | { ok:false, error }.
1011
+ async function placeManifestCreds(inst, mrow, deps) {
1012
+ const { log, apply } = deps;
1013
+ if (inst.hosting_shape === 'dedicated') {
1014
+ return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
1015
+ }
1016
+ if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
1017
+ const standalone = inst.hosting_shape === 'standalone';
1018
+ const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
1019
+ const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
1020
+ const sudoP = deps.privileged ? 'sudo ' : '';
1021
+
1022
+ // 1) exchange the code with GitHub (control-plane-side; the secret lands here only).
1023
+ let creds;
1024
+ if (apply) {
1025
+ creds = await (deps.convertManifestCode || convertManifestCode)(mrow.github_code, {});
1026
+ } else {
1027
+ log(` [oauth-manifest] would POST api.github.com/app-manifests/<code>/conversions for ${inst.slug}`);
1028
+ creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null };
1029
+ }
1030
+
1031
+ // 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
1032
+ // the same length/charset guard oauth-secret.js uses against silent truncation).
1033
+ const idV = oauthSecret.validateClientId(creds.clientId);
1034
+ const secV = oauthSecret.validateClientSecret(creds.clientSecret);
1035
+ if (!idV.ok || !secV.ok) {
1036
+ const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
1037
+ return { ok: false, error: `GitHub returned unusable creds (${why})` };
1038
+ }
1039
+
1040
+ // 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
1041
+ // writeFile logs only a byte count; the value is base64/temp-file placed).
1042
+ log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
1043
+ boxWriteFile(webEnvPath(inst), webEnvBody(inst, { clientId: creds.clientId, clientSecret: creds.clientSecret }), { mode: 0o600, sudo: true });
1044
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1045
+
1046
+ // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1047
+ if (apply) {
1048
+ let authConfigured = false;
1049
+ for (let i = 0; i < 10; i++) {
1050
+ const out = boxExec(healthzCmd(inst), { allowFail: true });
1051
+ if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
1052
+ await new Promise((r) => setTimeout(r, 1000));
1053
+ }
1054
+ if (!authConfigured) {
1055
+ return { ok: false, error: 'placed creds but /healthz still reports auth_configured=false — sign-in not live yet' };
1056
+ }
1057
+ log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
1058
+ } else {
1059
+ log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
1060
+ }
1061
+ return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
1062
+ }
1063
+
1064
+ // Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
1065
+ // Runs on the SAME timer tick as the intent queue (called from cmdRunIntents). Each row
1066
+ // is claimed atomically (status → 'converting', SKIP LOCKED) so overlapping ticks are
1067
+ // safe; a retryable failure resets it to 'code_received' for a future tick until the
1068
+ // attempt cap, then 'error'. A `seen` set stops a hot re-claim loop within one tick.
1069
+ async function drainOAuthManifests(deps) {
1070
+ const { db, provisioning, log, apply } = deps;
1071
+ if (!apply) {
1072
+ const { rows } = await db.query(
1073
+ `SELECT m.id, i.slug FROM provisioning_oauth_manifests m
1074
+ JOIN provisioning_instances i ON i.id = m.instance_id
1075
+ WHERE m.status = 'code_received' ORDER BY m.created_at`);
1076
+ if (rows.length) {
1077
+ log(`oauth-manifest runner (dry-run): ${rows.length} awaiting conversion`);
1078
+ for (const r of rows) log(` would convert + place creds for ${r.slug} (manifest #${r.id})`);
1079
+ }
1080
+ return { converted: 0, errors: 0 };
1081
+ }
1082
+ let converted = 0, errors = 0;
1083
+ const seen = new Set();
1084
+ for (;;) {
1085
+ const mrow = await provisioning.claimNextOAuthManifest(db);
1086
+ if (!mrow) break;
1087
+ if (seen.has(mrow.id)) {
1088
+ // Already tried this row this tick (a retryable failure reset it). Put it back and
1089
+ // stop, so a persistent failure retries on the NEXT tick instead of hot-looping.
1090
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', {}).catch(() => {});
1091
+ break;
1092
+ }
1093
+ seen.add(mrow.id);
1094
+ if (mrow.attempts > MAX_INTENT_ATTEMPTS) {
1095
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: `exceeded ${MAX_INTENT_ATTEMPTS} attempts` });
1096
+ errors++; log(` ✖ oauth-manifest #${mrow.id} retired (too many attempts)`); continue;
1097
+ }
1098
+ try {
1099
+ const inst = await provisioning.getInstanceById(db, mrow.instance_id);
1100
+ if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
1101
+ const result = await placeManifestCreds(inst, mrow, deps);
1102
+ if (result && result.ok === false) {
1103
+ if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
1104
+ else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
1105
+ errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
1106
+ } else {
1107
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug });
1108
+ await provisioning.recordEvent(db, {
1109
+ instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
1110
+ detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
1111
+ }).catch(() => {});
1112
+ converted++;
1113
+ }
1114
+ } catch (e) {
1115
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: e.message }).catch(() => {});
1116
+ errors++; log(` ✖ oauth-manifest #${mrow.id}: ${e.message}`);
1117
+ }
1118
+ }
1119
+ if (converted || errors) log(`oauth-manifest — converted=${converted} errors=${errors}`);
1120
+ return { converted, errors };
1121
+ }
1122
+
1123
+ // ---------------------------------------------------------------------------
1124
+ // run-intents — drain provisioning_intents (the systemd-timer entry point). The
1125
+ // box.js cmdRunIntents shape: dry-run lists the pending queue; --apply claims +
1126
+ // executes each, with a poison-attempt cap. FOR UPDATE SKIP LOCKED (in
1127
+ // provisioning.claimNextIntent) makes overlapping ticks safe. It ALSO drains the
1128
+ // GitHub-App-Manifest conversion queue on the same tick (task 2080).
1129
+ // ---------------------------------------------------------------------------
1130
+ async function cmdRunIntents(deps) {
1131
+ const { db, provisioning, log, apply } = deps;
1132
+ if (!apply) {
1133
+ const { rows } = await db.query(
1134
+ `SELECT pi.*, i.slug FROM provisioning_intents pi
1135
+ JOIN provisioning_instances i ON i.id = pi.instance_id
1136
+ WHERE pi.state = 'pending' ORDER BY pi.created_at`);
1137
+ log(`provision intent runner (dry-run): ${rows.length} pending intent(s)`);
1138
+ for (const r of rows) log(` would ${r.action} ${r.slug} (intent #${r.id}, attempts=${r.attempts})`);
1139
+ if (rows.length === 0) log(' queue empty — exit clean');
1140
+ await drainOAuthManifests(deps);
1141
+ return { ran: 0 };
1142
+ }
1143
+ let ran = 0, ok = 0, errors = 0;
1144
+ for (;;) {
1145
+ const intent = await provisioning.claimNextIntent(db);
1146
+ if (!intent) break;
1147
+ ran++;
1148
+ if (intent.attempts > MAX_INTENT_ATTEMPTS) {
1149
+ await provisioning.resolveIntent(db, intent.id, 'error', `exceeded ${MAX_INTENT_ATTEMPTS} attempts`);
1150
+ errors++; log(` ✖ intent #${intent.id} retired (too many attempts)`); continue;
1151
+ }
1152
+ try {
1153
+ const inst = await provisioning.getInstanceById(db, intent.instance_id);
1154
+ if (!inst) { await provisioning.resolveIntent(db, intent.id, 'error', 'instance row vanished'); errors++; continue; }
1155
+ const result = intent.action === 'provision'
1156
+ ? await provisionInstance(inst, deps)
1157
+ : await teardownInstance(inst, deps);
1158
+ if (result && result.ok === false) {
1159
+ if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1160
+ else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1161
+ errors++;
1162
+ } else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
1163
+ } catch (e) {
1164
+ await provisioning.resolveIntent(db, intent.id, 'error', e.message).catch(() => {});
1165
+ // Surface the failure on the instance too, so a stuck instance is visible.
1166
+ await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message }).catch(() => {});
1167
+ errors++; log(` ✖ intent #${intent.id}: ${e.message}`);
1168
+ }
1169
+ }
1170
+ // Same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane.
1171
+ const m = await drainOAuthManifests(deps);
1172
+ log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
1173
+ return { ran, ok, errors, manifests: m };
1174
+ }
1175
+
1176
+ // A light drift reconcile: surface instances stuck mid-flight (provisioning /
1177
+ // tearing_down) with no open intent — a runner died mid-op. Reports; --apply
1178
+ // re-enqueues a fresh intent so the next tick retries. (Full droplet-vs-DB drift,
1179
+ // the box.js reconcile-drift analogue, is a later hardening.)
1180
+ async function cmdReconcile(deps) {
1181
+ const { db, provisioning, log, apply } = deps;
1182
+ const { rows } = await db.query(
1183
+ `SELECT i.* FROM provisioning_instances i
1184
+ WHERE i.status IN ('provisioning','tearing_down')
1185
+ AND NOT EXISTS (SELECT 1 FROM provisioning_intents pi
1186
+ WHERE pi.instance_id = i.id AND pi.state IN ('pending','running'))`);
1187
+ log(`reconcile: ${rows.length} instance(s) stuck mid-flight with no open intent`);
1188
+ for (const inst of rows) {
1189
+ const action = inst.status === 'tearing_down' ? 'teardown' : 'provision';
1190
+ log(` ${inst.slug} [${inst.status}] → re-enqueue ${action}`);
1191
+ if (apply) await provisioning.enqueueIntent(db, inst.id, action, 'runner:reconcile');
1192
+ }
1193
+ return { stuck: rows.length };
1194
+ }
1195
+
1196
+ async function cmdStatus(deps) {
1197
+ const { db, log } = deps;
1198
+ const { rows } = await db.query(
1199
+ `SELECT i.slug, i.status, i.hosting_shape, i.port, i.domain, i.cost_estimate_usd, b.github_login
1200
+ FROM provisioning_instances i JOIN builders b ON b.id = i.owner_builder_id
1201
+ ORDER BY i.updated_at DESC LIMIT 200`);
1202
+ if (rows.length === 0) { log('no provisioned instances yet.'); return { count: 0 }; }
1203
+ log('provisioned instances:');
1204
+ let monthly = 0;
1205
+ for (const r of rows) {
1206
+ monthly += Number(r.cost_estimate_usd || 0);
1207
+ log(` ${String(r.slug).padEnd(20)} ${String(r.status).padEnd(13)} ${r.hosting_shape.padEnd(10)} port=${r.port || '-'} @${r.github_login} $${Number(r.cost_estimate_usd || 0).toFixed(2)}/mo ${r.domain || ''}`);
1208
+ }
1209
+ log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo`);
1210
+ return { count: rows.length };
1211
+ }
1212
+
1213
+ // ---------------------------------------------------------------------------
1214
+ // CLI
1215
+ // ---------------------------------------------------------------------------
1216
+ function usage() {
1217
+ console.log(`provision.js — instance-provisioning control-plane runner (ADR 0111, #1944)
1218
+
1219
+ Usage:
1220
+ node scripts/gds/provision.js <command> [slug] [--apply]
1221
+
1222
+ Commands:
1223
+ run-intents drain provisioning_intents (the systemd-timer entry point):
1224
+ execute each pending provision/teardown intent
1225
+ provision <slug> provision one instance by slug (composes the standup runbook)
1226
+ teardown <slug> tear down one instance by slug
1227
+ reconcile re-enqueue instances stuck mid-flight with no open intent
1228
+ status list provisioned instances + estimated run-rate
1229
+ dns-check <domain> PREFLIGHT: verify the control-plane Cloudflare token can reach
1230
+ the domain's zone BEFORE the address step (network-only, no DB)
1231
+
1232
+ DRY-RUN by default — nothing mutates without --apply. The LIVE leg (real createdb /
1233
+ migrate / systemctl / caddy / DigitalOcean) needs the DO/Cloudflare tokens from
1234
+ /etc/<inst>/box.env (systemd EnvironmentFile=) and runs on the control plane only.
1235
+
1236
+ Set PROVISION_SUDO=1 (the control-plane runner unit does) to escalate the privileged
1237
+ steps via sudo — required when the runner runs as a non-root user (ADR 0128).
1238
+
1239
+ Set PROVISION_REMOTE_HOST=<user@host> (+ PROVISION_REMOTE_CHECKOUT, PROVISION_PUBLIC_IP)
1240
+ to run the box-mutating steps on a REMOTE co-hosting box over ssh, so the control
1241
+ plane can orchestrate co-tenant workloads that live there without that box holding
1242
+ any cloud token (ADR 0130). The Cloudflare DNS hook always stays on the control plane.
1243
+
1244
+ STANDALONE instances (their own repo dir, ADR 0108) run control-plane-LOCAL even under
1245
+ PROVISION_REMOTE_HOST — their scaffolded dir lives on the control plane, not the remote
1246
+ box (task 2074). Set PROVISION_CONTROL_PLANE_IP to this box's own public IP so a
1247
+ standalone instance's A record points HERE, not at the co-hosting box's PROVISION_PUBLIC_IP.`);
1248
+ }
1249
+
1250
+ async function main() {
1251
+ const args = process.argv.slice(2);
1252
+ const cmd = args[0];
1253
+ const apply = hasFlag(args, '--apply');
1254
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
1255
+
1256
+ // dns-check <domain> — the DNS token-scope PREFLIGHT (task 1980). Network-only + no
1257
+ // DB, so onboarding can run it BEFORE the connect-address step (and before any
1258
+ // provision). Exits 0 if the token can reach the domain's zone, 1 with guidance if not.
1259
+ if (cmd === 'dns-check') {
1260
+ const domain = args[1] && !args[1].startsWith('--') ? args[1] : null;
1261
+ if (!domain) { console.error('dns-check needs a <domain>'); process.exit(2); }
1262
+ const scope = await checkDnsTokenScope(domain);
1263
+ if (scope.reachable) { console.log(`✓ DNS token can reach zone ${scope.zone} for ${domain} — ${scope.reason}`); process.exit(0); }
1264
+ console.error(`✖ DNS token cannot reach zone ${scope.zone} for ${domain}: ${scope.reason}`);
1265
+ console.error(` → ${scope.guidance}`);
1266
+ process.exit(1);
1267
+ }
1268
+
1269
+ const known = ['run-intents', 'provision', 'teardown', 'reconcile', 'status'];
1270
+ if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
1271
+ const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1272
+ if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
1273
+
1274
+ let pool, provisioning, DoApi;
1275
+ try { ({ pool, provisioning, DoApi } = loadDeps()); }
1276
+ catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
1277
+
1278
+ // Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
1279
+ // local) for the box-mutating steps; controlExec is ALWAYS local — the token
1280
+ // step (Cloudflare DNS hook) + the standalone repo scaffold run on the control
1281
+ // plane where the token + packaging tools live (task 2065, ADR 0130).
1282
+ const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
1283
+ const controlExec = makeExec({ apply });
1284
+ const deps = {
1285
+ db: pool, provisioning, apply, privileged: CONFIG.privileged, log: console.log,
1286
+ exec, controlExec,
1287
+ writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
1288
+ controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
1289
+ doApi: DoApi ? new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) }) : null,
1290
+ };
1291
+
1292
+ let result;
1293
+ try {
1294
+ if (cmd === 'run-intents') result = await cmdRunIntents(deps);
1295
+ else if (cmd === 'provision') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await provisionInstance(inst, deps); }
1296
+ else if (cmd === 'teardown') { const inst = await provisioning.getInstanceBySlug(pool, slug); if (!inst) { console.error(`no instance with slug '${slug}'`); await pool.end().catch(() => {}); process.exit(1); } result = await teardownInstance(inst, deps); }
1297
+ else if (cmd === 'reconcile') result = await cmdReconcile(deps);
1298
+ else if (cmd === 'status') result = await cmdStatus(deps);
1299
+ } catch (e) {
1300
+ console.error('fatal:', e && e.message ? e.message : e);
1301
+ await pool.end().catch(() => {});
1302
+ process.exit(1);
1303
+ }
1304
+ await pool.end().catch(() => {});
1305
+ process.exit(result && result.ok === false ? 1 : 0);
1306
+ }
1307
+
1308
+ if (require.main === module) main();
1309
+
1310
+ module.exports = {
1311
+ CONFIG, MAX_INTENT_ATTEMPTS,
1312
+ // pure step generators (tested directly)
1313
+ dbName, dbCreateCmd, migrateCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
1314
+ standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1315
+ // standalone scaffold (ADR 0108 / 0125, task 2055)
1316
+ parseTargetRef, instanceRepoRemote, instanceInitSpec, scaffoldStandaloneRepo, coreVersionSafe,
1317
+ zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
1318
+ caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
1319
+ // executors + orchestration (injectable deps for tests)
1320
+ makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1321
+ cmdRunIntents, cmdReconcile, cmdStatus,
1322
+ // GitHub App Manifest flow (task 2080, ADR 0133)
1323
+ convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1324
+ };