@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,1255 @@
1
+ // tests/boxes.mjs
2
+ //
3
+ // Hermetic unit smoke for the per-builder box lifecycle (ADR 0031 §2/§5, #598).
4
+ // Covers the PURE decision core of modules/dev-box/boxes.js (rank gate, size/cost
5
+ // catalog, idle/dormant SELECTION, transition legality) and the dry-run/live
6
+ // contract of modules/dev-box/do-api.js — all with NO database, NO DigitalOcean, NO
7
+ // network, and NO real clock (nowMs is always injected). The DB-backed
8
+ // orchestration in scripts/gds/box.js is exercised on the control plane in
9
+ // dry-run; the decisions it relies on are pinned here.
10
+ //
11
+ // Run: node tests/boxes.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const boxes = require('../modules/dev-box/boxes.js');
18
+ const { DoApi, DoApiError } = require('../scripts/gds/do-api.js');
19
+ const { isTunnelReadyStatus } = require('../modules/dev-box/routes/box.js');
20
+
21
+ let passed = 0;
22
+ let failed = 0;
23
+ function t(name, fn) {
24
+ try { fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+ async function ta(name, fn) {
28
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
29
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
30
+ }
31
+
32
+ const HOUR = 3_600_000;
33
+ const DAY = 86_400_000;
34
+ const NOW = Date.parse('2026-06-04T12:00:00Z');
35
+
36
+ console.log('rank gate:');
37
+
38
+ t('rankMeetsBoxFloor: ordering xenos < thetes < metic < archon', () => {
39
+ assert.equal(boxes.rankMeetsBoxFloor('xenos', 'xenos'), true);
40
+ assert.equal(boxes.rankMeetsBoxFloor('metic', 'xenos'), true);
41
+ assert.equal(boxes.rankMeetsBoxFloor('archon', 'metic'), true);
42
+ assert.equal(boxes.rankMeetsBoxFloor('xenos', 'metic'), false);
43
+ assert.equal(boxes.rankMeetsBoxFloor('metic', 'archon'), false);
44
+ // ADR 0034 regression (#869): thetes sits above the xenos floor, below metic.
45
+ assert.equal(boxes.rankMeetsBoxFloor('thetes', 'xenos'), true);
46
+ assert.equal(boxes.rankMeetsBoxFloor('thetes', 'metic'), false);
47
+ assert.equal(boxes.rankMeetsBoxFloor('metic', 'thetes'), true);
48
+ });
49
+
50
+ t('rankMeetsBoxFloor: unknown rank or floor is false (fail-closed)', () => {
51
+ assert.equal(boxes.rankMeetsBoxFloor('bogus', 'xenos'), false);
52
+ assert.equal(boxes.rankMeetsBoxFloor(undefined, 'xenos'), false);
53
+ assert.equal(boxes.rankMeetsBoxFloor('archon', 'bogus'), false);
54
+ });
55
+
56
+ t('boxScopeForRank: xenos/thetes→starter, metic/archon→full', () => {
57
+ assert.equal(boxes.boxScopeForRank('xenos'), 'starter');
58
+ assert.equal(boxes.boxScopeForRank('metic'), 'full');
59
+ assert.equal(boxes.boxScopeForRank('archon'), 'full');
60
+ // ADR 0034: a Thetes may hold a box but earns no scope uplift — still 'starter'.
61
+ assert.equal(boxes.boxScopeForRank('thetes'), 'starter');
62
+ });
63
+
64
+ t('thetes can provision a box (ADR 0034 regression, #869)', () => {
65
+ // The bug: thetes missing from RANK_ORDER → fail-closed below the xenos floor
66
+ // → 403 on /box/ensure for the whole tier (builder 25 hit 6+ of these).
67
+ assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'thetes' }));
68
+ const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'thetes', status: 'active' });
69
+ assert.equal(d.action, 'provision');
70
+ });
71
+
72
+ t('assertProvisionAllowed: passes at/above floor, throws INSUFFICIENT_RANK below', () => {
73
+ // Default floor (xenos): even a Xenos may get a (scoped) box — ADR 0031 §5(b).
74
+ assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'xenos' }));
75
+ assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'archon' }));
76
+ // Raised floor: a Xenos is refused.
77
+ let err;
78
+ try { boxes.assertProvisionAllowed({ rank: 'xenos', floor: 'metic' }); }
79
+ catch (e) { err = e; }
80
+ assert.ok(err, 'should throw');
81
+ assert.equal(err.code, 'INSUFFICIENT_RANK');
82
+ assert.equal(err.required, 'metic');
83
+ assert.equal(err.actual, 'xenos');
84
+ });
85
+
86
+ console.log('box block (idea 257 / task 1163):');
87
+
88
+ t('blocked builder is denied a NEW box (state none → BOX_PROVISION_BLOCKED)', () => {
89
+ const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'metic', status: 'active', blocked: true });
90
+ assert.equal(d.action, 'denied');
91
+ assert.equal(d.reason, 'BOX_PROVISION_BLOCKED');
92
+ });
93
+
94
+ t('blocked builder cannot WAKE a parked box (state parked → BOX_PROVISION_BLOCKED)', () => {
95
+ const d = boxes.decideEnsureAction({ boxState: 'parked', rank: 'metic', status: 'active', blocked: true });
96
+ assert.equal(d.action, 'denied');
97
+ assert.equal(d.reason, 'BOX_PROVISION_BLOCKED');
98
+ });
99
+
100
+ t('block is FUTURE-ONLY: an already-active box still heartbeats (state active → ready)', () => {
101
+ // Lars's "block future opens only" default: blocking does not tear down a
102
+ // running box; the active box keeps heartbeating until it idles out or the
103
+ // Archon explicitly closes it (close_current).
104
+ const d = boxes.decideEnsureAction({ boxState: 'active', rank: 'metic', status: 'active', blocked: true });
105
+ assert.equal(d.action, 'ready');
106
+ });
107
+
108
+ t('block does not disturb an in-flight provision (state provisioning → in_progress)', () => {
109
+ const d = boxes.decideEnsureAction({ boxState: 'provisioning', rank: 'metic', status: 'active', blocked: true });
110
+ assert.equal(d.action, 'in_progress');
111
+ });
112
+
113
+ t('NOT blocked is unchanged (state none → provision)', () => {
114
+ const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'metic', status: 'active', blocked: false });
115
+ assert.equal(d.action, 'provision');
116
+ // Default (blocked omitted) must behave exactly as before this feature.
117
+ const d2 = boxes.decideEnsureAction({ boxState: 'parked', rank: 'metic', status: 'active' });
118
+ assert.equal(d2.action, 'wake');
119
+ });
120
+
121
+ console.log('size + cost catalog:');
122
+
123
+ t('default size is in the catalog and is the 4 GB box', () => {
124
+ assert.ok(boxes.SIZE_CATALOG[boxes.DEFAULT_SIZE], 'default size present');
125
+ assert.equal(boxes.DEFAULT_SIZE, 's-2vcpu-4gb');
126
+ });
127
+
128
+ t('computeActiveCostUsd: hours × hourly rate', () => {
129
+ const cost = boxes.computeActiveCostUsd({
130
+ sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW - 10 * HOUR, untilMs: NOW,
131
+ });
132
+ // 10h × 0.03571 ≈ 0.3571
133
+ assert.ok(Math.abs(cost - 0.3571) < 0.001, `expected ~0.3571, got ${cost}`);
134
+ });
135
+
136
+ t('computeActiveCostUsd: zero/negative/invalid interval → 0', () => {
137
+ assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW, untilMs: NOW }), 0);
138
+ assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW, untilMs: NOW - HOUR }), 0);
139
+ assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: null, untilMs: NOW }), 0);
140
+ assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 'no-such-size', activeSinceMs: NOW - HOUR, untilMs: NOW }), 0);
141
+ });
142
+
143
+ console.log('idle / dormant selection:');
144
+
145
+ t('selectIdleBoxes: picks active boxes past the idle threshold, ignores others', () => {
146
+ const rows = [
147
+ { state: 'active', github_login: 'old', last_activity_at: new Date(NOW - 45 * 60000).toISOString() },
148
+ { state: 'active', github_login: 'fresh', last_activity_at: new Date(NOW - 5 * 60000).toISOString() },
149
+ { state: 'parked', github_login: 'parked', last_activity_at: new Date(NOW - 999 * 60000).toISOString() },
150
+ ];
151
+ const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
152
+ assert.equal(idle.length, 1);
153
+ assert.equal(idle[0].github_login, 'old');
154
+ });
155
+
156
+ t('selectIdleBoxes: COALESCEs to active_since when never heartbeated', () => {
157
+ const rows = [
158
+ // No heartbeat, but active_since is recent → NOT idle (full window from boot).
159
+ { state: 'active', github_login: 'booting', last_activity_at: null, active_since: new Date(NOW - 2 * 60000).toISOString() },
160
+ // No heartbeat, active_since old → idle.
161
+ { state: 'active', github_login: 'stale', last_activity_at: null, active_since: new Date(NOW - 90 * 60000).toISOString() },
162
+ ];
163
+ const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
164
+ assert.deepEqual(idle.map((r) => r.github_login), ['stale']);
165
+ });
166
+
167
+ t('selectIdleBoxes: guards bad inputs', () => {
168
+ assert.deepEqual(boxes.selectIdleBoxes(null, { idleMinutes: 30, nowMs: NOW }), []);
169
+ assert.deepEqual(boxes.selectIdleBoxes([], { idleMinutes: 0, nowMs: NOW }), []);
170
+ assert.deepEqual(boxes.selectIdleBoxes([], { idleMinutes: 30, nowMs: NaN }), []);
171
+ });
172
+
173
+ t('selectIdleBoxes: skips boxes where claude_active=true regardless of age', () => {
174
+ const rows = [
175
+ // Old + claude running → kept alive.
176
+ { state: 'active', github_login: 'clauding', last_activity_at: new Date(NOW - 60 * 60000).toISOString(), claude_active: true },
177
+ // Old + claude NOT running → idle.
178
+ { state: 'active', github_login: 'idle', last_activity_at: new Date(NOW - 60 * 60000).toISOString(), claude_active: false },
179
+ // Old + no claude_active field (legacy row) → idle.
180
+ { state: 'active', github_login: 'legacy', last_activity_at: new Date(NOW - 60 * 60000).toISOString() },
181
+ ];
182
+ const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
183
+ assert.deepEqual(idle.map((r) => r.github_login).sort(), ['idle', 'legacy']);
184
+ });
185
+
186
+ t('selectDormantBoxes: picks parked boxes past the dormant threshold', () => {
187
+ const rows = [
188
+ { state: 'parked', github_login: 'gone', parked_at: new Date(NOW - 20 * DAY).toISOString() },
189
+ { state: 'parked', github_login: 'recent', parked_at: new Date(NOW - 3 * DAY).toISOString() },
190
+ { state: 'active', github_login: 'working', parked_at: null },
191
+ ];
192
+ const dormant = boxes.selectDormantBoxes(rows, { dormantDays: 14, nowMs: NOW });
193
+ assert.equal(dormant.length, 1);
194
+ assert.equal(dormant[0].github_login, 'gone');
195
+ });
196
+
197
+ console.log('transition legality:');
198
+
199
+ t('canTransition: legal moves', () => {
200
+ assert.equal(boxes.canTransition('provision', 'none'), true);
201
+ assert.equal(boxes.canTransition('provision', 'destroyed'), true);
202
+ assert.equal(boxes.canTransition('park', 'active'), true);
203
+ assert.equal(boxes.canTransition('wake', 'parked'), true);
204
+ assert.equal(boxes.canTransition('deprovision', 'parked'), true);
205
+ assert.equal(boxes.canTransition('deprovision', 'active'), true);
206
+ });
207
+
208
+ t('canTransition: illegal moves', () => {
209
+ assert.equal(boxes.canTransition('provision', 'active'), false, 'no double-provision');
210
+ assert.equal(boxes.canTransition('park', 'parked'), false, 'no double-park');
211
+ assert.equal(boxes.canTransition('wake', 'active'), false, 'no wake an active box');
212
+ assert.equal(boxes.canTransition('park', 'none'), false);
213
+ });
214
+
215
+ console.log('\nprovision dry-run inertness (#1034):');
216
+
217
+ // cmdProvision is dependency-injected (pool, boxes, boxOnboard) and box.js exports
218
+ // it for tests (the CLI IIFE is require.main-guarded). We inject recording fakes
219
+ // so we can assert the literal contract the #1034 bug violated: a dry-run
220
+ // (no --apply) must not write to builder_boxes — i.e. ensureBoxRow / setBoxState /
221
+ // recordEvent must never fire, and a MISSING row (getBoxByBuilderId → null) must
222
+ // still be treated as the provisionable 'none' state.
223
+ const boxCli = require('../scripts/gds/box.js');
224
+
225
+ function fakeProvisionDeps({ existingBox = null } = {}) {
226
+ const calls = [];
227
+ const fakeBoxes = {
228
+ assertProvisionAllowed() {}, // no throw → rank gate passes
229
+ boxScopeForRank() { return 'full'; },
230
+ canTransition: boxes.canTransition, // exercise the REAL transition map
231
+ async getBoxByBuilderId() { calls.push('getBoxByBuilderId'); return existingBox; },
232
+ async ensureBoxRow() { calls.push('ensureBoxRow'); return { id: 1, state: 'none', provisioned_at: null }; },
233
+ async setBoxState() { calls.push('setBoxState'); return {}; },
234
+ async recordEvent() { calls.push('recordEvent'); },
235
+ async setTerminalAccess() { calls.push('setTerminalAccess'); },
236
+ };
237
+ const fakeOnboard = { async listSshKeys() { return []; }, wrapManagedBlock() { return ''; } };
238
+ const pool = {
239
+ async query() {
240
+ return { rows: [{ id: 42, github_login: 'tester', display_name: 'T', rank: 'metic', status: 'active' }] };
241
+ },
242
+ };
243
+ return { calls, fakeBoxes, fakeOnboard, pool };
244
+ }
245
+
246
+ await ta('cmdProvision dry-run (no row yet) reads the gate but writes NOTHING to builder_boxes', async () => {
247
+ const { calls, fakeBoxes, fakeOnboard, pool } = fakeProvisionDeps({ existingBox: null });
248
+ const res = await boxCli.cmdProvision(pool, fakeBoxes, fakeOnboard, 'tester', { apply: false });
249
+ assert.equal(res.dryRun, true, 'dry-run returns {dryRun:true}');
250
+ assert.ok(calls.includes('getBoxByBuilderId'), 'gate reads via getBoxByBuilderId');
251
+ assert.ok(!calls.includes('ensureBoxRow'), 'dry-run must NOT ensureBoxRow (the #1034 bug)');
252
+ assert.ok(!calls.includes('setBoxState'), 'dry-run must NOT write box state');
253
+ assert.ok(!calls.includes('recordEvent'), 'dry-run must NOT write box_events');
254
+ });
255
+
256
+ await ta('cmdProvision dry-run still rejects an illegal transition (active box → no double-provision)', async () => {
257
+ const { calls, fakeBoxes, fakeOnboard, pool } = fakeProvisionDeps({ existingBox: { state: 'active' } });
258
+ const res = await boxCli.cmdProvision(pool, fakeBoxes, fakeOnboard, 'tester', { apply: false });
259
+ assert.equal(res.ok, false, "an 'active' box cannot be re-provisioned");
260
+ assert.match(res.error, /'active'/);
261
+ assert.ok(!calls.includes('ensureBoxRow'), 'a rejected dry-run also writes nothing');
262
+ });
263
+
264
+ console.log('DigitalOcean client (do-api):');
265
+
266
+ await ta('dry-run is the DEFAULT and sends NO network request', async () => {
267
+ let called = 0;
268
+ const api = new DoApi({ fetchImpl: () => { called++; throw new Error('should not fetch'); } });
269
+ assert.equal(api.dryRun, true);
270
+ const r = await api.createDroplet({ name: 'x', region: 'nyc3', size: 's-2vcpu-4gb', image: 'ubuntu-24-04-x64' });
271
+ assert.equal(called, 0, 'fetch must not be called in dry-run');
272
+ assert.equal(r.dryRun, true);
273
+ assert.ok(r.droplet && String(r.droplet.id).startsWith('dryrun-'), 'returns a synthetic droplet');
274
+ });
275
+
276
+ await ta('live mode without a token throws clearly (never silent no-op)', async () => {
277
+ assert.throws(
278
+ () => new DoApi({ dryRun: false }),
279
+ (e) => e instanceof DoApiError && /DO_API_TOKEN/.test(e.message)
280
+ );
281
+ });
282
+
283
+ await ta('live createDroplet builds the right request via injected fetch', async () => {
284
+ const seen = {};
285
+ const fakeFetch = async (url, opts) => {
286
+ seen.url = url; seen.opts = opts;
287
+ return { ok: true, status: 202, text: async () => JSON.stringify({ droplet: { id: 123, status: 'new' } }) };
288
+ };
289
+ const api = new DoApi({ dryRun: false, token: 'tok_test', fetchImpl: fakeFetch });
290
+ const r = await api.createDroplet({ name: 'otb-box-someone', region: 'nyc3', size: 's-2vcpu-4gb', image: 'ubuntu-24-04-x64', tags: ['otb-builder-box'] });
291
+ assert.equal(r.droplet.id, 123);
292
+ assert.match(seen.url, /\/v2\/droplets$/);
293
+ assert.equal(seen.opts.method, 'POST');
294
+ assert.equal(seen.opts.headers.Authorization, 'Bearer tok_test');
295
+ const body = JSON.parse(seen.opts.body);
296
+ assert.equal(body.name, 'otb-box-someone');
297
+ assert.equal(body.size, 's-2vcpu-4gb');
298
+ });
299
+
300
+ await ta('live mode surfaces a DO error body as DoApiError', async () => {
301
+ const fakeFetch = async () => ({ ok: false, status: 422, text: async () => JSON.stringify({ id: 'unprocessable', message: 'bad size' }) });
302
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
303
+ let err;
304
+ try { await api.getDroplet(1); } catch (e) { err = e; }
305
+ assert.ok(err instanceof DoApiError);
306
+ assert.equal(err.status, 422);
307
+ assert.match(err.message, /bad size/);
308
+ });
309
+
310
+ console.log('DB helpers (fake pg client — no real Postgres):');
311
+
312
+ // A fake pg client: records every query and returns canned rows in order.
313
+ function fakeDb(responses = []) {
314
+ const calls = [];
315
+ let i = 0;
316
+ return {
317
+ calls,
318
+ async query(sql, params) { calls.push({ sql, params }); return responses[i++] || { rows: [] }; },
319
+ };
320
+ }
321
+
322
+ await ta('recordComputeCost: tallies + writes a cost_log row keyed PER INTERVAL', async () => {
323
+ const db = fakeDb();
324
+ const box = { id: 7, builder_id: 42, size_slug: 's-2vcpu-4gb', active_since: new Date(NOW - 5 * HOUR).toISOString() };
325
+ const cost = await boxes.recordComputeCost(db, { box, untilMs: NOW, actor: 'sweep:idle' });
326
+ assert.ok(Math.abs(cost - 0.1786) < 0.001, `5h × rate ≈ 0.1786, got ${cost}`);
327
+ // 3 writes: UPDATE tally, INSERT box_events(cost), INSERT cost_log.
328
+ assert.equal(db.calls.length, 3);
329
+ const costLog = db.calls.find((c) => /INSERT INTO cost_log/.test(c.sql));
330
+ assert.ok(costLog, 'cost_log insert fired');
331
+ const sourceRef = costLog.params[2];
332
+ // source_ref must be per-interval (box id + interval end), so a second park
333
+ // of the same box does not collide on cost_log's UNIQUE(source, source_ref).
334
+ assert.match(sourceRef, /^builder_boxes#7@\d+$/, `source_ref per-interval, got ${sourceRef}`);
335
+ assert.equal(costLog.params[3], 42, 'builder_id attributed');
336
+ });
337
+
338
+ await ta('recordComputeCost: zero interval writes nothing', async () => {
339
+ const db = fakeDb();
340
+ const box = { id: 1, builder_id: 2, size_slug: 's-2vcpu-4gb', active_since: null };
341
+ const cost = await boxes.recordComputeCost(db, { box, untilMs: NOW });
342
+ assert.equal(cost, 0);
343
+ assert.equal(db.calls.length, 0, 'no writes for a zero-cost interval');
344
+ });
345
+
346
+ await ta('bumpActivity: an active box resets its clock with NO per-heartbeat event', async () => {
347
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
348
+ const state = await boxes.bumpActivity(db, 42);
349
+ assert.equal(state, 'active');
350
+ assert.equal(db.calls.length, 1, 'only the UPDATE — heartbeats must not churn box_events');
351
+ assert.match(db.calls[0].sql, /UPDATE builder_boxes[\s\S]*state = 'active'/);
352
+ });
353
+
354
+ await ta('bumpActivity: with claudeActive=true writes claude_active column', async () => {
355
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
356
+ const state = await boxes.bumpActivity(db, 42, { claudeActive: true });
357
+ assert.equal(state, 'active');
358
+ assert.match(db.calls[0].sql, /claude_active = \$2/);
359
+ assert.deepEqual(db.calls[0].params, [42, true]);
360
+ });
361
+
362
+ await ta('bumpActivity: without claudeActive does NOT write claude_active column', async () => {
363
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
364
+ await boxes.bumpActivity(db, 42);
365
+ assert.ok(!db.calls[0].sql.includes('claude_active'), 'no claude_active column when flag not supplied');
366
+ });
367
+
368
+ await ta('bumpActivity: claudeActive=true stamps first_connected_at write-once (#823)', async () => {
369
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
370
+ await boxes.bumpActivity(db, 42, { claudeActive: true });
371
+ // COALESCE so a later beat never moves it — the monotonic "connected" signal.
372
+ assert.match(db.calls[0].sql, /first_connected_at = COALESCE\(first_connected_at, now\(\)\)/);
373
+ // Still no extra bind param — first_connected_at uses now() inline.
374
+ assert.deepEqual(db.calls[0].params, [42, true]);
375
+ });
376
+
377
+ await ta('bumpActivity: claudeActive=false does NOT touch first_connected_at (never un-ticks)', async () => {
378
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
379
+ await boxes.bumpActivity(db, 42, { claudeActive: false });
380
+ assert.ok(!db.calls[0].sql.includes('first_connected_at'), 'a claude_active=false beat leaves first_connected_at intact');
381
+ });
382
+
383
+ await ta('hasEverConnected: true when first_connected_at is set, false otherwise (#823)', async () => {
384
+ const connected = fakeDb([{ rows: [{ connected: true }] }]);
385
+ assert.equal(await boxes.hasEverConnected(connected, 42), true);
386
+ assert.match(connected.calls[0].sql, /first_connected_at IS NOT NULL/);
387
+
388
+ const notYet = fakeDb([{ rows: [{ connected: false }] }]);
389
+ assert.equal(await boxes.hasEverConnected(notYet, 42), false);
390
+
391
+ const noRow = fakeDb([{ rows: [] }]); // no box row at all
392
+ assert.equal(await boxes.hasEverConnected(noRow, 42), false);
393
+ });
394
+
395
+ await ta('setBoxState: rejects a non-allowlisted column (injection guard)', async () => {
396
+ const db = fakeDb([{ rows: [{ id: 1 }] }]);
397
+ await assert.rejects(
398
+ () => boxes.setBoxState(db, 1, 'active', { 'ip = oops; DROP TABLE builder_boxes; --': 'x' }),
399
+ /not.*allowlist|allowlist/i
400
+ );
401
+ // A legit column still goes through, parameterized.
402
+ const db2 = fakeDb([{ rows: [{ id: 1, state: 'active' }] }]);
403
+ const row = await boxes.setBoxState(db2, 1, 'active', { ip: '1.2.3.4', snapshot_id: null });
404
+ assert.ok(row);
405
+ assert.match(db2.calls[0].sql, /UPDATE builder_boxes SET state = \$2/);
406
+ });
407
+
408
+ await ta('setBoxState: host_keys + host_keys_at are allowlisted, parameterized (idea 310)', async () => {
409
+ const db = fakeDb([{ rows: [{ id: 1, state: 'destroyed' }] }]);
410
+ const row = await boxes.setBoxState(db, 1, 'destroyed', { host_keys: null, host_keys_at: null });
411
+ assert.ok(row);
412
+ // Settable (no allowlist throw) and bound as params, never interpolated.
413
+ assert.match(db.calls[0].sql, /host_keys = \$\d+/);
414
+ assert.match(db.calls[0].sql, /host_keys_at = \$\d+/);
415
+ assert.deepEqual(db.calls[0].params, [1, 'destroyed', null, null]);
416
+ });
417
+
418
+ console.log('\nidea 310 — a rebuilt box never serves a stale SSH identity:');
419
+
420
+ await ta('deprovisionOne clears host_keys + host_keys_at on teardown', async () => {
421
+ // The lockout bug: a reaped box rebuilds with fresh host keys, but the GDS row
422
+ // kept the OLD keys (~10 min) so GET /box/me served a stale identity and the
423
+ // desktop app pinned the WRONG key. deprovisionOne must null them.
424
+ const calls = [];
425
+ const fakeBoxes = {
426
+ async recordComputeCost() { calls.push('recordComputeCost'); return 0; },
427
+ async recordStorageCost() { calls.push('recordStorageCost'); return 0; },
428
+ async setBoxState(_db, _bid, state, patch) { calls.push({ state, patch }); return {}; },
429
+ async recordEvent() { calls.push('recordEvent'); },
430
+ };
431
+ const doApi = { dryRun: true, async deleteDroplet() {}, async deleteSnapshot() {} };
432
+ // No droplet/snapshot/parked_at → no cost or DO calls; CF/DNS unconfigured in
433
+ // the test env → tunnel + blackhole-DNS teardown are skipped.
434
+ const box = {
435
+ id: 7, builder_id: 42, state: 'active', droplet_id: null, snapshot_id: null,
436
+ parked_at: null, github_login: 'tester', hostname: 'box-tester.dev.example.com',
437
+ };
438
+ await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test:sweep', login: 'tester' });
439
+ const destroyed = calls.find((c) => c && c.state === 'destroyed');
440
+ assert.ok(destroyed, 'sets state destroyed');
441
+ assert.equal(destroyed.patch.host_keys, null, 'host_keys cleared on teardown');
442
+ assert.equal(destroyed.patch.host_keys_at, null, 'host_keys_at cleared on teardown');
443
+ });
444
+
445
+ await ta('bumpActivity: a non-active box is a no-op that reports current state', async () => {
446
+ // UPDATE matches nothing → falls through to a state read.
447
+ const db = fakeDb([{ rows: [] }, { rows: [{ state: 'parked' }] }]);
448
+ const state = await boxes.bumpActivity(db, 42);
449
+ assert.equal(state, 'parked');
450
+ assert.equal(db.calls.length, 2, 'no heartbeat event written for a non-active box');
451
+ });
452
+
453
+ console.log('\nensure decision (#701 onboarding middleware):');
454
+
455
+ t('decideEnsureAction: inactive builder is denied (fail-closed, checked first)', () => {
456
+ const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'archon', status: 'inactive' });
457
+ assert.equal(d.action, 'denied');
458
+ assert.equal(d.reason, 'BUILDER_INACTIVE');
459
+ });
460
+
461
+ t('decideEnsureAction: below the floor is denied with required/actual', () => {
462
+ const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'xenos', status: 'active', floor: 'metic' });
463
+ assert.equal(d.action, 'denied');
464
+ assert.equal(d.reason, 'INSUFFICIENT_RANK');
465
+ assert.equal(d.required, 'metic');
466
+ assert.equal(d.actual, 'xenos');
467
+ });
468
+
469
+ t('decideEnsureAction: no box → provision (auto-provision); a Xenos qualifies at the default floor', () => {
470
+ for (const s of ['none', 'destroyed', 'error', undefined, null]) {
471
+ assert.equal(boxes.decideEnsureAction({ boxState: s, rank: 'xenos', status: 'active' }).action, 'provision', `state=${s}`);
472
+ }
473
+ });
474
+
475
+ t('decideEnsureAction: parked → wake (wake-on-connect)', () => {
476
+ assert.equal(boxes.decideEnsureAction({ boxState: 'parked', rank: 'xenos', status: 'active' }).action, 'wake');
477
+ });
478
+
479
+ t('decideEnsureAction: active → ready; in-flight states → in_progress', () => {
480
+ assert.equal(boxes.decideEnsureAction({ boxState: 'active', rank: 'metic', status: 'active' }).action, 'ready');
481
+ for (const s of ['provisioning', 'waking', 'parking']) {
482
+ assert.equal(boxes.decideEnsureAction({ boxState: s, rank: 'metic', status: 'active' }).action, 'in_progress', `state=${s}`);
483
+ }
484
+ });
485
+
486
+ t('decideEnsureAction: an unknown state fails closed to in_progress (never a blind provision)', () => {
487
+ assert.equal(boxes.decideEnsureAction({ boxState: 'frobnicating', rank: 'metic', status: 'active' }).action, 'in_progress');
488
+ });
489
+
490
+ console.log('\nintent queue (#701):');
491
+
492
+ await ta('enqueueIntent: a fresh insert returns created=true and always enqueues state=pending', async () => {
493
+ const db = fakeDb([{ rows: [{ id: 1, builder_id: 42, action: 'provision', state: 'pending' }] }]);
494
+ const { intent, created } = await boxes.enqueueIntent(db, 42, 'provision', 'api:self');
495
+ assert.equal(created, true);
496
+ assert.equal(intent.action, 'provision');
497
+ assert.equal(intent.state, 'pending', 'enqueueIntent always enqueues a plain pending intent');
498
+ assert.match(db.calls[0].sql, /INSERT INTO box_intents/);
499
+ });
500
+
501
+ await ta('getOpenIntent: a pending or running intent is open (blocks a duplicate enqueue)', async () => {
502
+ const db = fakeDb([{ rows: [{ id: 9, state: 'pending' }] }]);
503
+ const open = await boxes.getOpenIntent(db, 7);
504
+ assert.equal(open.state, 'pending');
505
+ // Only the two live states gate a re-enqueue — there is no approval-hold state.
506
+ assert.match(db.calls[0].sql, /state IN \('pending', 'running'\)/);
507
+ });
508
+
509
+ await ta('enqueueIntent: a unique-violation (open intent exists) returns the existing one, created=false', async () => {
510
+ const db = {
511
+ calls: [],
512
+ async query(sql) {
513
+ this.calls.push({ sql });
514
+ if (/INSERT INTO box_intents/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
515
+ return { rows: [{ id: 9, builder_id: 42, action: 'wake', state: 'pending' }] };
516
+ },
517
+ };
518
+ const { intent, created } = await boxes.enqueueIntent(db, 42, 'wake', 'api:self');
519
+ assert.equal(created, false);
520
+ assert.equal(intent.id, 9);
521
+ });
522
+
523
+ await ta('claimNextIntent: claims with FOR UPDATE SKIP LOCKED and flips to running', async () => {
524
+ const db = fakeDb([{ rows: [{ id: 3, action: 'provision', state: 'running' }] }]);
525
+ const intent = await boxes.claimNextIntent(db);
526
+ assert.equal(intent.id, 3);
527
+ assert.match(db.calls[0].sql, /FOR UPDATE SKIP LOCKED/);
528
+ assert.match(db.calls[0].sql, /state = 'running'/);
529
+ });
530
+
531
+ await ta('setTerminalAccess: only an active box stores the url + credential', async () => {
532
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
533
+ const state = await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com', credential: 's3cr3t' });
534
+ assert.equal(state, 'active');
535
+ assert.match(db.calls[0].sql, /UPDATE builder_boxes[\s\S]*state = 'active'/);
536
+ assert.match(db.calls[0].sql, /terminal_url = \$2[\s\S]*terminal_credential = \$3/);
537
+ assert.deepEqual(db.calls[0].params, [42, 'https://abc.trycloudflare.com', 's3cr3t']);
538
+ });
539
+
540
+ await ta('setTerminalAccess: a missing credential is stored as null', async () => {
541
+ const db = fakeDb([{ rows: [{ state: 'active' }] }]);
542
+ await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com' });
543
+ assert.equal(db.calls[0].params[2], null);
544
+ });
545
+
546
+ await ta('setTerminalAccess: a non-active box does not store, reports current state', async () => {
547
+ const db = fakeDb([{ rows: [] }, { rows: [{ state: 'parked' }] }]);
548
+ const state = await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com', credential: 'x' });
549
+ assert.equal(state, 'parked');
550
+ assert.equal(db.calls.length, 2);
551
+ });
552
+
553
+ console.log('\ngit non-interactive (the #854 ttyd/SSH input-freeze fix):');
554
+
555
+ t('infra pins GIT_TERMINAL_PROMPT=0 across every box arrival path (#854)', () => {
556
+ const fs = require('node:fs');
557
+ const path = require('node:path');
558
+ const { fileURLToPath } = require('node:url');
559
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
560
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
561
+
562
+ const unit = read('infra/box-terminal.service');
563
+ const cloudInit = read('infra/builder-box-cloud-init.yaml');
564
+ const fetchSh = read('infra/box-source-fetch.sh');
565
+
566
+ // 1. ttyd process tree: the systemd unit pins the env var for the whole tree
567
+ // (bash -l -> claude -> any git). This is the canonical file box.js inlines.
568
+ assert.match(unit, /^Environment=GIT_TERMINAL_PROMPT=0$/m,
569
+ 'box-terminal.service must pin GIT_TERMINAL_PROMPT=0 for the ttyd process tree');
570
+
571
+ // 2. Fresh boxes (cloud-init write_files): a profile.d export for LOGIN shells
572
+ // AND an /etc/environment entry (pam_env) for NON-login SSH are both baked.
573
+ assert.match(cloudInit, /path: \/etc\/profile\.d\/05-git-noninteractive\.sh/,
574
+ 'cloud-init must write the profile.d git-noninteractive file');
575
+ assert.match(cloudInit, /export GIT_TERMINAL_PROMPT=0/,
576
+ 'cloud-init profile.d entry must export GIT_TERMINAL_PROMPT=0');
577
+ assert.match(cloudInit, /path: \/etc\/environment[\s\S]{0,160}GIT_TERMINAL_PROMPT=0/,
578
+ 'cloud-init must add GIT_TERMINAL_PROMPT=0 to /etc/environment (covers non-login SSH)');
579
+
580
+ // 3. Existing boxes self-heal: the source-fetch cron re-asserts BOTH files
581
+ // (runs before the token check, idempotent, no disruptive restart).
582
+ assert.match(fetchSh, /GIT_NOPROMPT_FILE=\/etc\/profile\.d\/05-git-noninteractive\.sh/,
583
+ 'box-source-fetch.sh must re-assert the profile.d file on existing boxes');
584
+ assert.match(fetchSh, /grep -qs '\^GIT_TERMINAL_PROMPT=0\$' \/etc\/environment/,
585
+ 'box-source-fetch.sh must re-assert /etc/environment on existing boxes');
586
+
587
+ // 4. Delivery wiring intact: cloud-init still inlines the canonical unit (the
588
+ // #774 placeholder box.js base64-fills), so a fresh box gets the unit env.
589
+ assert.match(cloudInit, /__BOX_TERMINAL_SERVICE_B64__/,
590
+ 'cloud-init must still inline box-terminal.service via its placeholder');
591
+ });
592
+
593
+ t('box-source-fetch.sh reports host keys IMMEDIATELY, not only via the */10 cron (task 1460)', () => {
594
+ const fs = require('node:fs');
595
+ const path = require('node:path');
596
+ const { fileURLToPath } = require('node:url');
597
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
598
+ const fetchSh = fs.readFileSync(path.join(ROOT, 'infra/box-source-fetch.sh'), 'utf8');
599
+
600
+ // The off->on bug: a re-provision mints FRESH /etc/ssh host keys and clears the
601
+ // GDS's stored copy (idea 310), but the reporter ran ONLY on a */10 cron — so
602
+ // GET /box/me had no key for up to 10 min and the laptop's stale known_hosts pin
603
+ // stranded the connect ("Host denied"). box-source-fetch.sh runs at first boot
604
+ // (cloud-init) and every */10, so it must INVOKE the reporter directly — not just
605
+ // wire the cron — to close that window from minutes to seconds.
606
+ assert.match(fetchSh, /echo "\*\/10 \* \* \* \* \$WORKSPACE\/infra\/box-report-host-keys\.sh/,
607
+ 'box-source-fetch.sh must still wire the */10 host-keys cron (the backstop)');
608
+ assert.match(fetchSh, /^\s*"\$WORKSPACE\/infra\/box-report-host-keys\.sh" >> \/tmp\/box-report-host-keys\.log 2>&1 \|\| true\s*$/m,
609
+ 'box-source-fetch.sh must ALSO invoke the host-keys reporter immediately (task 1460), non-fatal under set -e');
610
+ });
611
+
612
+ t('box-heartbeat.sh counts an open ttyd browser terminal as in-use (idea #294 / task 1161)', () => {
613
+ const fs = require('node:fs');
614
+ const path = require('node:path');
615
+ const { fileURLToPath } = require('node:url');
616
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
617
+ const hb = fs.readFileSync(path.join(ROOT, 'infra/box-heartbeat.sh'), 'utf8');
618
+
619
+ // The bug: the browser on-ramp (#760/#774) reaches the box via ttyd over the
620
+ // Cloudflare tunnel — not SSH, no utmp, ~0% CPU — so the heartbeat saw "idle"
621
+ // and the sweep destroyed an in-use box. An OPEN browser terminal holds an
622
+ // ESTABLISHED connection to ttyd's local port (default 7681, overridable).
623
+ assert.match(hb, /TTYD_PORT="\$\{BOX_TTYD_PORT:-7681\}"/,
624
+ 'must default the ttyd port to 7681, overridable via BOX_TTYD_PORT');
625
+ assert.match(hb, /state established "\( sport = :\$TTYD_PORT \)"/,
626
+ 'must detect an ESTABLISHED connection to the ttyd port (the listening socket is filtered out)');
627
+ assert.match(hb, /ACTIVE=1; TTYD_CONNECTED=1/,
628
+ 'an open ttyd session must set ACTIVE=1 so the heartbeat actually pings');
629
+ assert.match(hb, /if \[ "\$TTYD_CONNECTED" -eq 1 \]; then CLAUDE_ACTIVE="true"; fi/,
630
+ 'an open ttyd session must report claude_active=true so the reaper spares it');
631
+ });
632
+
633
+ t('#893: terminal session persists (tmux) + close-box teardown command is wired', () => {
634
+ const fs = require('node:fs');
635
+ const path = require('node:path');
636
+ const { fileURLToPath } = require('node:url');
637
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
638
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
639
+
640
+ const unit = read('infra/box-terminal.service');
641
+ const cloudInit = read('infra/builder-box-cloud-init.yaml');
642
+ const fetchSh = read('infra/box-source-fetch.sh');
643
+ const boxJs = read('scripts/gds/box.js');
644
+ const closeBox = read('infra/close-box.sh');
645
+ const launch = read('infra/otb-box-launch.sh');
646
+
647
+ // --- persistence: task 986 moved the tmux logic OUT of box-terminal.service's
648
+ // inline ExecStart into /usr/local/bin/otb-box-launch (canonical copy:
649
+ // infra/otb-box-launch.sh) and made it auto-run `claude`. #893's intent is
650
+ // unchanged -- a browser disconnect only detaches the tmux CLIENT; the "otb"
651
+ // session (with claude) survives on the box and the next connect reattaches
652
+ // -- it just lives in the launcher now. So assert the service INVOKES the
653
+ // launcher, and that the tmux attach-or-create persistence + `bash -l`
654
+ // fallback live in the launcher.
655
+ assert.match(unit, /--writable \/usr\/local\/bin\/otb-box-launch/,
656
+ 'box-terminal.service must run ttyd against /usr/local/bin/otb-box-launch (task 986)');
657
+ assert.match(launch, /tmux has-session -t otb/,
658
+ 'otb-box-launch must reuse the existing "otb" tmux session when present (#893 persistence)');
659
+ assert.match(launch, /tmux new-session -d -s otb\b/,
660
+ 'otb-box-launch must create the "otb" tmux session when absent (#893 attach-or-create)');
661
+ assert.match(launch, /exec tmux attach-session -t otb/,
662
+ 'otb-box-launch must attach to the persistent "otb" session on connect (#893)');
663
+ assert.match(launch, /command -v tmux[\s\S]*?else[\s\S]*?exec bash -l/,
664
+ 'otb-box-launch must fall back to bash -l when tmux is absent');
665
+ // fresh boxes get tmux from the package list.
666
+ assert.match(cloudInit, /packages:[\s\S]*?^\s*- tmux$/m,
667
+ 'cloud-init must install tmux so the persistence path works on fresh boxes');
668
+ // launcher delivery chain (mirrors the close-box chain below): canonical infra
669
+ // copy -> cloud-init placeholder on first boot -> box.js gz+b64 fill ->
670
+ // box-source-fetch self-heal on existing boxes.
671
+ assert.match(cloudInit, /path: \/usr\/local\/bin\/otb-box-launch[\s\S]{0,160}__OTB_BOX_LAUNCH_B64__/,
672
+ 'cloud-init must write /usr/local/bin/otb-box-launch via its placeholder');
673
+ assert.match(boxJs, /__OTB_BOX_LAUNCH_B64__[\s\S]{0,80}fileGzB64\('otb-box-launch\.sh'\)/,
674
+ "box.js must gz+b64-fill the otb-box-launch placeholder from infra/otb-box-launch.sh");
675
+ assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/otb-box-launch\.sh" \/usr\/local\/bin\/otb-box-launch/,
676
+ 'box-source-fetch.sh must self-heal /usr/local/bin/otb-box-launch on existing boxes');
677
+
678
+ // --- close-box command: canonical script + first-boot delivery (cloud-init
679
+ // placeholder box.js fills) + existing-box self-heal in source-fetch.
680
+ assert.match(closeBox, /\/box\/close/,
681
+ 'infra/close-box.sh must POST to /box/close (the deprovision action)');
682
+ assert.match(cloudInit, /path: \/usr\/local\/bin\/close-box[\s\S]{0,120}__CLOSE_BOX_B64__/,
683
+ 'cloud-init must write /usr/local/bin/close-box via its placeholder');
684
+ assert.match(boxJs, /__CLOSE_BOX_B64__[\s\S]{0,60}fileGzB64\('close-box\.sh'\)/,
685
+ "box.js must gz+b64-fill the close-box placeholder from infra/close-box.sh");
686
+ assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/close-box\.sh" \/usr\/local\/bin\/close-box/,
687
+ 'box-source-fetch.sh must self-heal /usr/local/bin/close-box on existing boxes');
688
+ });
689
+
690
+ console.log('\n#913 - terminal credential drift (systemd % expansion + self-heal):');
691
+
692
+ t('no unescaped % in any infra/*.service Exec line (#913 class guard)', () => {
693
+ const fs = require('node:fs');
694
+ const path = require('node:path');
695
+ const { fileURLToPath } = require('node:url');
696
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
697
+ const dir = path.join(ROOT, 'infra');
698
+ const units = fs.readdirSync(dir).filter((n) => n.endsWith('.service'));
699
+ assert.ok(units.length > 0, 'expected at least one infra/*.service unit');
700
+ for (const name of units) {
701
+ const lines = fs.readFileSync(path.join(dir, name), 'utf8').split('\n');
702
+ lines.forEach((line, i) => {
703
+ if (!/^\s*Exec\w*=/.test(line)) return;
704
+ // systemd expands % specifiers in Exec* lines; a literal % must be %%.
705
+ const residue = line.split('%%').join('');
706
+ assert.ok(!residue.includes('%'),
707
+ `infra/${name}:${i + 1} has an unescaped % in an Exec line - systemd ` +
708
+ 'expands % specifiers before the shell runs (write %% for a literal %). ' +
709
+ 'This is the #913 bug class: printf "%s" became a directive-free format ' +
710
+ 'and seeded a garbage ttyd password.');
711
+ });
712
+ }
713
+ });
714
+
715
+ t('box-terminal.service seeds the ttyd password via escaped %%s printf (#913)', () => {
716
+ const fs = require('node:fs');
717
+ const path = require('node:path');
718
+ const { fileURLToPath } = require('node:url');
719
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
720
+ const unit = fs.readFileSync(path.join(ROOT, 'infra/box-terminal.service'), 'utf8');
721
+ assert.match(unit, /printf "%%s" "\$BOX_TERMINAL_CREDENTIAL"/,
722
+ 'the ExecStartPre seed must printf "%%s" so the shell sees printf "%s"');
723
+ });
724
+
725
+ t('box-report-terminal.sh self-heals live ttyd credential drift (#913)', () => {
726
+ const fs = require('node:fs');
727
+ const path = require('node:path');
728
+ const { fileURLToPath } = require('node:url');
729
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
730
+ const sh = fs.readFileSync(path.join(ROOT, 'infra/box-report-terminal.sh'), 'utf8');
731
+ // reads the password the RUNNING ttyd enforces from its argv...
732
+ assert.match(sh, /\/proc\/\$TTYD_PID\/cmdline/,
733
+ 'must read the live ttyd argv to learn the password actually being enforced');
734
+ assert.match(sh, /--credential/,
735
+ 'must locate the --credential argv entry');
736
+ // ...and restarts the unit on mismatch so ttyd re-reads CRED_FILE.
737
+ assert.match(sh, /systemctl restart box-terminal/,
738
+ 'must restart box-terminal when the live password differs from CRED_FILE');
739
+ });
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // #889 — GET /box/terminal readiness probe must NOT treat a Cloudflare
743
+ // tunnel-down (HTTP 530 / error 1033) page as a live tunnel. The terminal URL
744
+ // is recorded at provision time, before cloudflared connects, so a too-eager
745
+ // probe handed out a clickable link that 1033s (builder 25, term-iammasterqua).
746
+ // Ready ONLY when the ORIGIN (ttyd) answered: 401 basic-auth or 2xx/3xx.
747
+ // ---------------------------------------------------------------------------
748
+ t('terminal probe: 401 (ttyd basic-auth challenge) is the ready signal', () => {
749
+ assert.equal(isTunnelReadyStatus(401), true);
750
+ });
751
+ t('terminal probe: 2xx/3xx (origin answered) is ready', () => {
752
+ assert.equal(isTunnelReadyStatus(200), true);
753
+ assert.equal(isTunnelReadyStatus(204), true);
754
+ assert.equal(isTunnelReadyStatus(302), true);
755
+ });
756
+ t('terminal probe: CF 1033 tunnel-down (530) is NOT ready (the #889 bug)', () => {
757
+ assert.equal(isTunnelReadyStatus(530), false);
758
+ });
759
+ t('terminal probe: CF origin-unreachable 5xx are NOT ready', () => {
760
+ for (const s of [500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526]) {
761
+ assert.equal(isTunnelReadyStatus(s), false, `status ${s} must not be ready`);
762
+ }
763
+ });
764
+ t('terminal probe: a 4xx other than 401 is NOT ready', () => {
765
+ assert.equal(isTunnelReadyStatus(403), false);
766
+ assert.equal(isTunnelReadyStatus(404), false);
767
+ });
768
+
769
+ console.log('\nlive game preview (#898/#903, ADR 0044) + cloud-init 64KB budget:');
770
+
771
+ t('#903: rendered cloud-init user-data stays under DigitalOcean\'s 64KB cap', () => {
772
+ const fs = require('node:fs');
773
+ const path = require('node:path');
774
+ const zlib = require('node:zlib');
775
+ const { fileURLToPath } = require('node:url');
776
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
777
+ const infra = (n) => path.join(ROOT, 'infra', n);
778
+ const gz = (n) => zlib.gzipSync(fs.readFileSync(infra(n))).toString('base64');
779
+
780
+ // Mirror box.js readCloudInit's substitution: EVERY inlined infra file is gz+b64
781
+ // (#903 margin; task 1314's source-fetch growth blew the cap, task 1321 gz+b64'd
782
+ // the rest), and ONLY the dynamic runtime values (keys/env/session) are plain b64.
783
+ // Realistic named-tunnel secrets (2 keys, tokens) give a worst-case render size.
784
+ let out = fs.readFileSync(infra('builder-box-cloud-init.yaml'), 'utf8')
785
+ .split('__BOX_SOURCE_FETCH_B64__').join(gz('box-source-fetch.sh'))
786
+ .split('__BOX_AUTHKEYS_FETCH_B64__').join(gz('box-authorized-keys-fetch.sh'))
787
+ .split('__BOX_TERMINAL_SERVICE_B64__').join(gz('box-terminal.service'))
788
+ .split('__BOX_TERMINAL_CREDSYNC_SERVICE_B64__').join(gz('box-terminal-credsync.service'))
789
+ .split('__BOX_TERMINAL_CREDENTIAL_PATH_B64__').join(gz('box-terminal-credential.path'))
790
+ .split('__CLOUDFLARED_SERVICE_B64__').join(gz('cloudflared.service'))
791
+ .split('__CLOUDFLARED_RUN_B64__').join(gz('cloudflared-run.sh'))
792
+ .split('__CLOSE_BOX_B64__').join(gz('close-box.sh'))
793
+ .split('__REPO_URL__').join('git@github.com:owner/amazonprimea.git')
794
+ .split('__BUILDER_AUTHORIZED_KEYS_B64__').join(Buffer.from('ssh-ed25519 AAAA...k1\nssh-ed25519 AAAA...k2\n').toString('base64'))
795
+ .split('__BOX_ENV_B64__').join(Buffer.from('BOX_TUNNEL_TOKEN=' + 'x'.repeat(200) + '\nBOX_TERMINAL_HOSTNAME=term-x.amazonprimea.com\nBOX_TERMINAL_CREDENTIAL=' + 'a'.repeat(48) + '\nBOX_PREVIEW_HOSTNAME=sandbox-x.amazonprimea.com\n').toString('base64'))
796
+ .split('__GDS_SESSION_B64__').join(Buffer.from(JSON.stringify({ token: 'x'.repeat(64), builder: { id: 1, github_login: 'x', rank: 'archon' }, api_base: 'https://amazonprimea.com' })).toString('base64'));
797
+ const bytes = Buffer.byteLength(out, 'utf8');
798
+ assert.ok(bytes < 64 * 1024, `rendered cloud-init is ${bytes}B; must stay under DO's 65536B cap`);
799
+ });
800
+
801
+ t('task 1040: cloud-init keeps SSH host keys stable across wake (ssh_deletekeys:false drop-in) + stays ASCII', () => {
802
+ const fs = require('node:fs');
803
+ const path = require('node:path');
804
+ const { fileURLToPath } = require('node:url');
805
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
806
+ const raw = fs.readFileSync(path.join(ROOT, 'infra', 'builder-box-cloud-init.yaml'), 'utf8');
807
+
808
+ // The box is recreated from a snapshot on every `wake` (new instance-id), and
809
+ // cmdWake passes NO user_data — so without ssh_deletekeys:false baked INTO the
810
+ // snapshot, cloud-init's cc_ssh regenerates /etc/ssh/ssh_host_* on every wake
811
+ // and stales every builder's known_hosts pin (Claude Desktop's strict ssh2
812
+ // verifier then hard-fails with "Host denied (verification failed)"). The
813
+ // drop-in must live in cloud.cfg.d so it rides in the snapshot and is parsed on
814
+ // every wake boot.
815
+ assert.match(raw, /path: \/etc\/cloud\/cloud\.cfg\.d\/99-otb-keep-hostkeys\.cfg/,
816
+ 'cloud-init must write the host-key-persistence drop-in into /etc/cloud/cloud.cfg.d/');
817
+ assert.match(raw, /ssh_deletekeys:\s*false/,
818
+ 'the drop-in must set ssh_deletekeys: false so cc_ssh preserves the snapshot host keys');
819
+
820
+ // cloud-init's YAML loader silently DISCARDS the entire config on any non-ASCII
821
+ // byte (learning #48 / #724) — which would void this drop-in along with it.
822
+ // Guard that the whole file stays 7-bit ASCII.
823
+ const nonAscii = [...raw].findIndex((c) => c.charCodeAt(0) > 127);
824
+ assert.equal(nonAscii, -1,
825
+ `builder-box-cloud-init.yaml must be ASCII-only (cloud-init drops non-ASCII configs); first offending char at index ${nonAscii}`);
826
+ });
827
+
828
+ t('#903: cloud-init gzips the big source-fetch script + box.js has the size guard', () => {
829
+ const fs = require('node:fs');
830
+ const path = require('node:path');
831
+ const { fileURLToPath } = require('node:url');
832
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
833
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
834
+
835
+ // The source-fetch write_files entry must use gz+b64 (that's the margin), and
836
+ // box.js must gzip it + fail fast before DO if the render exceeds the cap.
837
+ assert.match(read('infra/builder-box-cloud-init.yaml'),
838
+ /path: \/usr\/local\/bin\/box-source-fetch\.sh[\s\S]{0,80}encoding: gz\+b64/,
839
+ 'box-source-fetch.sh must be inlined as gz+b64');
840
+ const boxjs = read('scripts/gds/box.js');
841
+ assert.match(boxjs, /fileGzB64\('box-source-fetch\.sh'\)/, 'box.js must gz+b64 the source-fetch script');
842
+ assert.match(boxjs, /MAX_USERDATA_BYTES/, 'box.js must define the user-data size guard');
843
+ assert.match(boxjs, /over DigitalOcean's .* limit/, 'box.js must fail fast with an actionable over-limit message');
844
+ });
845
+
846
+ console.log('\nterminal credential race (#917, ideas #232/#233):');
847
+
848
+ t('#917: box-terminal.service never launches ttyd with an empty password and writes the credential atomically', () => {
849
+ const fs = require('node:fs');
850
+ const path = require('node:path');
851
+ const { fileURLToPath } = require('node:url');
852
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
853
+ const unit = fs.readFileSync(path.join(ROOT, 'infra', 'box-terminal.service'), 'utf8');
854
+
855
+ // ExecStart must read the credential into a var, refuse to start when empty
856
+ // (Restart=always retries), and never inline an unguarded $(cat ...).
857
+ assert.match(unit, /ExecStart=.*\[ -n "\$CRED" \] \|\|.*exit 1/,
858
+ 'ExecStart must refuse to launch ttyd with an empty credential');
859
+ assert.doesNotMatch(unit, /ExecStart=.*--credential "otb:\$\(cat /,
860
+ 'ExecStart must not inline an unguarded $(cat ...) as the password');
861
+ // ExecStartPre must write tmp + mv (atomic) — `printf > file` truncates first,
862
+ // opening the empty-read window that locked a blank password into ttyd.
863
+ assert.match(unit, /ExecStartPre=.*terminal-credential\.tmp && mv -f .*terminal-credential\.tmp/,
864
+ 'ExecStartPre must write the credential atomically (tmp + mv)');
865
+ // ...and wait for a credential source (named-tunnel race) before the random
866
+ // fallback, without stalling quick-tunnel boxes (box.env present, no var -> break).
867
+ assert.match(unit, /ExecStartPre=.*for i in \$\(seq[\s\S]*?sleep 5/,
868
+ 'ExecStartPre must poll/wait for the credential before falling back');
869
+ assert.match(unit, /\[ -f \/etc\/otb\/box\.env \] && break/,
870
+ 'a quick-tunnel box (box.env present, no baked credential) must skip the wait');
871
+ });
872
+
873
+ t('#917: the credential-drift watcher pair restarts ttyd only on a real mismatch', () => {
874
+ const fs = require('node:fs');
875
+ const path = require('node:path');
876
+ const { fileURLToPath } = require('node:url');
877
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
878
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
879
+
880
+ const pathUnit = read('infra/box-terminal-credential.path');
881
+ assert.match(pathUnit, /PathModified=\/etc\/otb\/terminal-credential/,
882
+ 'the path unit must watch the credential file (create + change)');
883
+ assert.match(pathUnit, /Unit=box-terminal-credsync\.service/,
884
+ 'the path unit must trigger credsync');
885
+
886
+ const credsync = read('infra/box-terminal-credsync.service');
887
+ assert.match(credsync, /Type=oneshot/, 'credsync must be a oneshot');
888
+ // Compares the live ttyd argv password to the file; restarts only on mismatch
889
+ // (loop-safe: after the restart they match, so the next trigger no-ops).
890
+ assert.match(credsync, /\/proc\/\$PID\/cmdline/, 'credsync must read the live ttyd argv');
891
+ assert.match(credsync, /\[ "\$RUN" = "\$FILE" \] && exit 0/,
892
+ 'credsync must no-op when the running password matches the file');
893
+ assert.match(credsync, /systemctl restart box-terminal/,
894
+ 'credsync must restart box-terminal on a real mismatch');
895
+ });
896
+
897
+ t('#917: delivery wiring — cloud-init (fresh boxes) + source-fetch self-heal (existing boxes)', () => {
898
+ const fs = require('node:fs');
899
+ const path = require('node:path');
900
+ const { fileURLToPath } = require('node:url');
901
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
902
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
903
+
904
+ const cloudInit = read('infra/builder-box-cloud-init.yaml');
905
+ assert.match(cloudInit, /path: \/etc\/systemd\/system\/box-terminal-credsync\.service[\s\S]{0,120}__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/,
906
+ 'cloud-init must inline box-terminal-credsync.service via its placeholder');
907
+ assert.match(cloudInit, /path: \/etc\/systemd\/system\/box-terminal-credential\.path[\s\S]{0,120}__BOX_TERMINAL_CREDENTIAL_PATH_B64__/,
908
+ 'cloud-init must inline box-terminal-credential.path via its placeholder');
909
+ assert.match(cloudInit, /systemctl enable --now box-terminal-credential\.path/,
910
+ 'cloud-init must arm the credential watcher at first boot');
911
+ // The runcmd credential seed must be atomic too (tmp + mv, same race as the unit).
912
+ assert.match(cloudInit, /terminal-credential\.tmp && mv -f \/etc\/otb\/terminal-credential\.tmp \/etc\/otb\/terminal-credential/,
913
+ 'the cloud-init credential seed must write atomically (tmp + mv)');
914
+
915
+ const boxjs = read('scripts/gds/box.js');
916
+ assert.match(boxjs, /fileGzB64\('box-terminal-credsync\.service'\)/,
917
+ 'box.js must gz+b64-fill the credsync placeholder');
918
+ assert.match(boxjs, /fileGzB64\('box-terminal-credential\.path'\)/,
919
+ 'box.js must gz+b64-fill the path-unit placeholder');
920
+
921
+ const fetchSh = read('infra/box-source-fetch.sh');
922
+ // Units refresh when they DIFFER (not only when absent — the idea #222 gap that
923
+ // kept already-provisioned boxes on stale units forever).
924
+ assert.match(fetchSh, /for UNIT in box-terminal\.service box-terminal-credsync\.service box-terminal-credential\.path cloudflared\.service/,
925
+ 'source-fetch must refresh all terminal units from the repo copy');
926
+ assert.match(fetchSh, /cmp -s "\$WORKSPACE\/infra\/\$UNIT" "\/etc\/systemd\/system\/\$UNIT"/,
927
+ 'source-fetch must compare repo vs installed unit (refresh on diff, not only on absent)');
928
+ // And every tick: arm the watcher + run one drift check (self-heals a raced box
929
+ // within one cron tick even if it predates the path unit).
930
+ assert.match(fetchSh, /systemctl enable --now box-terminal-credential\.path/,
931
+ 'source-fetch must arm the credential watcher on existing boxes');
932
+ assert.match(fetchSh, /systemctl start box-terminal-credsync\.service/,
933
+ 'source-fetch must run a drift check on every tick');
934
+ });
935
+
936
+ t('#1001/ADR 0052: live game preview enables on EVERY named-tunnel box (reverses #903 full-clone gate)', () => {
937
+ const fs = require('node:fs');
938
+ const path = require('node:path');
939
+ const { fileURLToPath } = require('node:url');
940
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
941
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
942
+
943
+ const fetchSh = read('infra/box-source-fetch.sh');
944
+ // ADR 0052: the game (public/, src/world, src/rooms) is now in the starter
945
+ // sparse scope and the unit runs the game-only entry, so the preview enables on
946
+ // EVERY box, gated ONLY on the named tunnel (BOX_PREVIEW_HOSTNAME). The
947
+ // src/rooms presence check survives as a belt-and-braces "clone finished" guard
948
+ // (it now passes on every box), not as a full-clone gate.
949
+ assert.match(fetchSh, /BOX_PREVIEW_HOSTNAME=.*box\.env/,
950
+ 'preview enable must still require the named-tunnel preview host');
951
+ assert.match(fetchSh, /-d "\$WORKSPACE\/src\/rooms"/,
952
+ 'preview enable keeps the src/rooms "game materialized" sanity guard');
953
+ assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/box-preview\.sh" \/usr\/local\/bin\/box-preview/,
954
+ 'box-source-fetch must install the box-preview command');
955
+ // box.js bakes the preview host so the box knows its own URL.
956
+ assert.match(read('scripts/gds/box.js'), /BOX_PREVIEW_HOSTNAME=\$\{ph\}/,
957
+ 'box.js must bake BOX_PREVIEW_HOSTNAME from the tunnel previewHostname');
958
+
959
+ // ADR 0052 — the game must be in the starter clone so a Xenos/Thetes box can
960
+ // serve the sandbox.
961
+ const boxAccess = read('modules/dev-box/box-access.js');
962
+ for (const p of ["'src/world'", "'src/rooms'", "'public'"]) {
963
+ assert.ok(boxAccess.includes(p), `STARTER_SPARSE_PATHS must include ${p} (ADR 0052)`);
964
+ }
965
+ // migrations/ stays excluded — a degraded-DB preview needs no schema.
966
+ assert.ok(!/['"]migrations['"]/.test(boxAccess.split('STARTER_SPARSE_PATHS')[1].split(']')[0]),
967
+ 'STARTER_SPARSE_PATHS must NOT include migrations/ (degraded-DB preview needs no schema)');
968
+
969
+ // The unit runs the GAME-ONLY entry, never the full server.js.
970
+ const svc = read('infra/box-game-preview.service');
971
+ assert.match(svc, /node \/workspace\/src\/preview-server\.js/,
972
+ 'box-game-preview.service must run the game-only entry');
973
+ assert.ok(!/ExecStart=.*node \/workspace\/server\.js/.test(svc),
974
+ 'box-game-preview.service must NOT run the full server.js');
975
+
976
+ // The game-only entry must never be able to mount the internal API surface.
977
+ // Test the CODE, not the prose — the file's own comments explain that it does
978
+ // NOT mount /api/gds, so strip comments before asserting on real wiring.
979
+ const prevCode = read('src/preview-server.js')
980
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
981
+ .split('\n').map((l) => l.replace(/\/\/.*$/, '')).join('\n'); // line comments
982
+ assert.ok(!/\brequire\(\s*['"][^'"]*\/gds\b/.test(prevCode),
983
+ 'src/preview-server.js must not require any src/bongos module');
984
+ assert.ok(!/buildGdsRouter/.test(prevCode),
985
+ 'src/preview-server.js must not import the GDS router builder');
986
+ assert.ok(!/\.use\(\s*['"]\/api\b/.test(prevCode),
987
+ 'src/preview-server.js must not mount any /api/* surface');
988
+ assert.ok(!/BuildersHallRoom/.test(prevCode),
989
+ 'src/preview-server.js must not register the internal BuildersHallRoom');
990
+ // …and it MUST register the game rooms via the shared helper (no drift).
991
+ assert.match(prevCode, /registerGameRooms\(/,
992
+ 'src/preview-server.js must register game rooms via the shared helper');
993
+ });
994
+
995
+ t('#1044: box-game-preview ExecStartPre guards on a real install sentinel, not a bare -d node_modules', () => {
996
+ const fs = require('node:fs');
997
+ const path = require('node:path');
998
+ const { fileURLToPath } = require('node:url');
999
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1000
+ const svc = fs.readFileSync(path.join(ROOT, 'infra/box-game-preview.service'), 'utf8');
1001
+ const guard = svc.split('\n').find((l) => l.startsWith('ExecStartPre=') && l.includes('npm'));
1002
+ assert.ok(guard, 'box-game-preview.service must have a dep-install ExecStartPre');
1003
+ // The antipattern (#1044): `-d node_modules` treats an empty-but-present dir as
1004
+ // "installed", so npm ci never runs and the preview crash-loops on
1005
+ // `Cannot find module 'express'`. The guard must probe a real install sentinel.
1006
+ assert.ok(!/\[\s*-d\s+\/workspace\/node_modules\s*\]/.test(guard),
1007
+ 'guard must NOT short-circuit on a bare `-d /workspace/node_modules` (empty dir passes it — #1044)');
1008
+ assert.match(guard, /\[\s*-[ef]\s+\/workspace\/node_modules\/(\.package-lock\.json|[^/\s]+\/package\.json)\s*\]/,
1009
+ 'guard must probe a real install sentinel (node_modules/.package-lock.json or a known dep\'s package.json)');
1010
+ // Still self-heals an uninstalled box.
1011
+ assert.match(guard, /npm ci --omit=dev \|\| npm install --omit=dev/,
1012
+ 'guard must still run npm ci (fallback npm install), --omit=dev, when the sentinel is absent');
1013
+ });
1014
+
1015
+ console.log('\nDO poll loops (waitForAction + settleDroplet share _poll, #1092 C8):');
1016
+
1017
+ await ta('settleDroplet returns {ip:null} in dry-run and never fetches', async () => {
1018
+ let called = 0;
1019
+ const api = new DoApi({ fetchImpl: () => { called++; throw new Error('should not fetch'); } });
1020
+ const r = await api.settleDroplet('drop1');
1021
+ assert.deepEqual(r, { ip: null });
1022
+ assert.equal(called, 0);
1023
+ });
1024
+
1025
+ await ta('settleDroplet polls getDroplet until active and returns the public IPv4', async () => {
1026
+ let n = 0;
1027
+ const fakeFetch = async () => {
1028
+ n++;
1029
+ const droplet = n >= 3
1030
+ ? { id: 'd1', status: 'active', networks: { v4: [{ type: 'private', ip_address: '10.0.0.1' }, { type: 'public', ip_address: '203.0.113.7' }] } }
1031
+ : { id: 'd1', status: 'new', networks: { v4: [] } };
1032
+ return { ok: true, status: 200, text: async () => JSON.stringify({ droplet }) };
1033
+ };
1034
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1035
+ const slept = [];
1036
+ const r = await api.settleDroplet('d1', { sleepFn: async (ms) => { slept.push(ms); } });
1037
+ assert.deepEqual(r, { ip: '203.0.113.7' });
1038
+ assert.equal(n, 3, 'polled until the droplet reported active');
1039
+ assert.equal(slept.length, 2, 'slept only between the two not-yet-active polls');
1040
+ });
1041
+
1042
+ await ta('settleDroplet throws once the try budget is exhausted', async () => {
1043
+ const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ droplet: { id: 'd1', status: 'new', networks: { v4: [] } } }) });
1044
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1045
+ await assert.rejects(
1046
+ () => api.settleDroplet('d1', { tries: 3, sleepFn: async () => {} }),
1047
+ /did not reach 'active'/
1048
+ );
1049
+ });
1050
+
1051
+ await ta('waitForAction returns true once the action completes', async () => {
1052
+ let n = 0;
1053
+ const fakeFetch = async () => { n++; return { ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: n >= 2 ? 'completed' : 'in-progress' } }) }; };
1054
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1055
+ const ok = await api.waitForAction('a1', { sleepFn: async () => {} });
1056
+ assert.equal(ok, true);
1057
+ assert.equal(n, 2);
1058
+ });
1059
+
1060
+ await ta('waitForAction throws DoApiError when the action errors', async () => {
1061
+ const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: 'errored' } }) });
1062
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1063
+ let err;
1064
+ try { await api.waitForAction('a1', { sleepFn: async () => {} }); } catch (e) { err = e; }
1065
+ assert.ok(err instanceof DoApiError);
1066
+ assert.match(err.message, /errored/);
1067
+ });
1068
+
1069
+ await ta('waitForAction throws on timeout (try budget exhausted)', async () => {
1070
+ const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: 'in-progress' } }) });
1071
+ const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1072
+ await assert.rejects(() => api.waitForAction('a1', { tries: 2, sleepFn: async () => {} }), /did not complete in time/);
1073
+ });
1074
+
1075
+ console.log('\ntask 1289 — droplet⇄row drift detection (planDropletDrift, pure):');
1076
+
1077
+ t('planDropletDrift: a droplet a live box owns is in sync (no drift)', () => {
1078
+ const droplets = [{ id: 111, name: 'otb-box-alice', tags: ['otb-builder-box', 'builder:alice'] }];
1079
+ const boxRows = [{ builder_id: 1, github_login: 'alice', state: 'active', droplet_id: '111' }];
1080
+ const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1081
+ assert.equal(zombies.length, 0);
1082
+ assert.equal(ghosts.length, 0);
1083
+ });
1084
+
1085
+ t('planDropletDrift: ZOMBIE — droplet exists but its box row is destroyed (the bug)', () => {
1086
+ // The exact task-1289 shape: deprovision marked the row destroyed + nulled
1087
+ // droplet_id, but the droplet was never actually deleted.
1088
+ const droplets = [{ id: 222, name: 'otb-box-lars589', tags: ['otb-builder-box', 'builder:lars589'], created_at: '2026-05-01T00:00:00Z' }];
1089
+ const boxRows = [{ builder_id: 3, github_login: 'lars589', state: 'destroyed', droplet_id: null }];
1090
+ const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1091
+ assert.equal(ghosts.length, 0);
1092
+ assert.equal(zombies.length, 1);
1093
+ assert.equal(zombies[0].dropletId, '222');
1094
+ assert.equal(zombies[0].login, 'lars589');
1095
+ assert.equal(zombies[0].builderId, 3, 'maps the zombie back to its builder for edge teardown');
1096
+ });
1097
+
1098
+ t('planDropletDrift: ZOMBIE — a droplet with no box row at all', () => {
1099
+ const droplets = [{ id: 333, name: 'otb-box-ghostuser', tags: ['otb-builder-box', 'builder:ghostuser'], created_at: '2026-01-01T00:00:00Z' }];
1100
+ const { zombies } = boxes.planDropletDrift({ droplets, boxRows: [], nowMs: NOW });
1101
+ assert.equal(zombies.length, 1);
1102
+ assert.equal(zombies[0].login, 'ghostuser');
1103
+ assert.equal(zombies[0].builderId, null);
1104
+ });
1105
+
1106
+ t('planDropletDrift: in-flight provision is NOT a zombie (young-droplet guard)', () => {
1107
+ // Droplet created 2 min before NOW; box still provisioning, droplet_id not yet
1108
+ // written. Must be skipped, never deleted.
1109
+ const created = new Date(NOW - 2 * 60_000).toISOString();
1110
+ const droplets = [{ id: 444, name: 'otb-box-newbie', tags: ['builder:newbie'], created_at: created }];
1111
+ const boxRows = [{ builder_id: 9, github_login: 'newbie', state: 'provisioning', droplet_id: null }];
1112
+ const { zombies, skippedYoung } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW, minAgeMinutes: 30 });
1113
+ assert.equal(zombies.length, 0, 'young droplet not killed');
1114
+ assert.equal(skippedYoung.length, 1);
1115
+ assert.equal(skippedYoung[0].dropletId, '444');
1116
+ });
1117
+
1118
+ t('planDropletDrift: GHOST — live row whose droplet vanished from DO', () => {
1119
+ const droplets = []; // DO has none
1120
+ const boxRows = [{ builder_id: 5, github_login: 'bob', state: 'active', droplet_id: '555' }];
1121
+ const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1122
+ assert.equal(zombies.length, 0);
1123
+ assert.equal(ghosts.length, 1);
1124
+ assert.equal(ghosts[0].builderId, 5);
1125
+ assert.equal(ghosts[0].dropletId, '555');
1126
+ assert.equal(ghosts[0].state, 'active');
1127
+ });
1128
+
1129
+ t('planDropletDrift: a parked box (no droplet) is never a ghost', () => {
1130
+ const boxRows = [{ builder_id: 6, github_login: 'carol', state: 'parked', droplet_id: null, snapshot_id: 's-1' }];
1131
+ const { ghosts } = boxes.planDropletDrift({ droplets: [], boxRows, nowMs: NOW });
1132
+ assert.equal(ghosts.length, 0);
1133
+ });
1134
+
1135
+ t('loginFromDroplet: prefers the builder: tag, falls back to the otb-box- name', () => {
1136
+ assert.equal(boxes.loginFromDroplet({ tags: ['otb-builder-box', 'builder:dave'], name: 'otb-box-x' }), 'dave');
1137
+ assert.equal(boxes.loginFromDroplet({ tags: ['otb-builder-box'], name: 'otb-box-erin' }), 'erin');
1138
+ assert.equal(boxes.loginFromDroplet({ tags: [], name: 'something-else' }), null);
1139
+ });
1140
+
1141
+ console.log('\ntask 1289 — idempotent delete + confirm-before-destroyed:');
1142
+
1143
+ await ta('deleteWithRetry: a 404 (already gone) is success, not failure', async () => {
1144
+ let calls = 0;
1145
+ const r = await boxCli.deleteWithRetry(async () => { calls++; const e = new Error('not found'); e.status = 404; throw e; }, { sleepFn: async () => {} });
1146
+ assert.equal(r.ok, true);
1147
+ assert.equal(r.alreadyGone, true);
1148
+ assert.equal(calls, 1, 'a 404 short-circuits — no retry');
1149
+ });
1150
+
1151
+ await ta('deleteWithRetry: retries a transient failure, then succeeds', async () => {
1152
+ let calls = 0;
1153
+ const r = await boxCli.deleteWithRetry(async () => { calls++; if (calls < 2) throw new Error('503 transient'); }, { sleepFn: async () => {} });
1154
+ assert.equal(r.ok, true);
1155
+ assert.equal(calls, 2);
1156
+ });
1157
+
1158
+ await ta('deleteWithRetry: a real failure after the retry budget returns ok:false', async () => {
1159
+ let calls = 0;
1160
+ const r = await boxCli.deleteWithRetry(async () => { calls++; throw new Error('500 boom'); }, { tries: 3, sleepFn: async () => {} });
1161
+ assert.equal(r.ok, false);
1162
+ assert.match(r.error.message, /boom/);
1163
+ assert.equal(calls, 3);
1164
+ });
1165
+
1166
+ await ta('deprovisionOne: a failed droplet delete parks at ERROR (not destroyed) and throws — the zombie fix', async () => {
1167
+ // This is the heart of task 1289: the row must NEVER reach 'destroyed' while the
1168
+ // droplet is still alive. A delete that keeps failing leaves state='error' with
1169
+ // droplet_id preserved (not nulled) and throws so the caller surfaces it.
1170
+ const states = [];
1171
+ const fakeBoxes = {
1172
+ async recordComputeCost() { return 0; },
1173
+ async recordStorageCost() { return 0; },
1174
+ async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1175
+ async recordEvent() {},
1176
+ };
1177
+ const doApi = { dryRun: false, async deleteDroplet() { throw new Error('DO 500'); }, async deleteSnapshot() {} };
1178
+ const box = { id: 7, builder_id: 42, state: 'active', droplet_id: '999', snapshot_id: null, parked_at: null, github_login: 'tester' };
1179
+ await assert.rejects(
1180
+ () => boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test', login: 'tester', sleepFn: async () => {} }),
1181
+ /DO 500/
1182
+ );
1183
+ assert.equal(states.length, 1, 'exactly one state write');
1184
+ assert.equal(states[0].state, 'error', 'parks at error, NOT destroyed');
1185
+ assert.ok(!('droplet_id' in states[0].patch), 'droplet_id is PRESERVED (not nulled) so the zombie stays queryable');
1186
+ assert.match(states[0].patch.error_note, /delete failed/);
1187
+ });
1188
+
1189
+ await ta('deprovisionOne: a droplet already gone (404) proceeds cleanly to destroyed', async () => {
1190
+ const states = [];
1191
+ const fakeBoxes = {
1192
+ async recordComputeCost() { return 0; },
1193
+ async recordStorageCost() { return 0; },
1194
+ async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1195
+ async recordEvent() {},
1196
+ };
1197
+ const doApi = { dryRun: false, async deleteDroplet() { const e = new Error('gone'); e.status = 404; throw e; }, async deleteSnapshot() {} };
1198
+ const box = { id: 8, builder_id: 43, state: 'active', droplet_id: '1000', snapshot_id: null, parked_at: null, github_login: 'tester2' };
1199
+ await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test', login: 'tester2', sleepFn: async () => {} });
1200
+ const destroyed = states.find((s) => s.state === 'destroyed');
1201
+ assert.ok(destroyed, 'a 404 droplet is treated as deleted → reaches destroyed');
1202
+ assert.equal(destroyed.patch.droplet_id, null);
1203
+ });
1204
+
1205
+ console.log('\ntask 2034 — a post-destroyed teardown failure must not clobber state=destroyed:');
1206
+
1207
+ await ta('deprovisionOne: a post-destroyed recordEvent throw does NOT undo the teardown (stays destroyed, no reject)', async () => {
1208
+ // The builder-95 false-error (2026-07-04): the droplet delete succeeded and the
1209
+ // row committed 'destroyed', but the follow-up audit event threw. That exception
1210
+ // used to propagate to the sweep's catch, which flipped the row back to 'error'
1211
+ // with a null droplet_id. The audit write is best-effort — it must be swallowed
1212
+ // so deprovisionOne resolves and 'destroyed' stands.
1213
+ const states = [];
1214
+ const fakeBoxes = {
1215
+ async recordComputeCost() { return 0; },
1216
+ async recordStorageCost() { return 0; },
1217
+ async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1218
+ async recordEvent() { throw new Error('box_events insert failed'); },
1219
+ };
1220
+ const doApi = { dryRun: false, async deleteDroplet() {}, async deleteSnapshot() {} };
1221
+ const box = { id: 9, builder_id: 95, state: 'active', droplet_id: '2000', snapshot_id: null, parked_at: null, github_login: 'jaxri' };
1222
+ // Must NOT reject — the teardown itself succeeded.
1223
+ await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'sweep:idle', login: 'jaxri', sleepFn: async () => {} });
1224
+ assert.equal(states.length, 1, 'exactly one state write');
1225
+ assert.equal(states[0].state, 'destroyed', "stays 'destroyed' despite the audit-event failure");
1226
+ assert.equal(states[0].patch.droplet_id, null, 'droplet_id nulled — the teardown is real');
1227
+ });
1228
+
1229
+ await ta('markErrorUnlessDestroyed: never flips an already-destroyed box back to error (guard)', async () => {
1230
+ // Second line of defence: even if some OTHER post-commit step throws (e.g. the
1231
+ // reconcile path's recordSourceEvent) and lands in a caller's catch, a box that
1232
+ // already reached 'destroyed' must be left alone.
1233
+ const writes = [];
1234
+ const fakeBoxes = {
1235
+ async getBoxByBuilderId() { return { state: 'destroyed' }; },
1236
+ async setBoxState(_db, _bid, state, patch) { writes.push({ state, patch }); return {}; },
1237
+ };
1238
+ await boxCli.markErrorUnlessDestroyed({}, fakeBoxes, 95, 'recordSourceEvent failed after teardown');
1239
+ assert.equal(writes.length, 0, 'a destroyed box is left alone — no error downgrade');
1240
+ });
1241
+
1242
+ await ta('markErrorUnlessDestroyed: a genuine (pre-destroyed) failure still parks at error (guard)', async () => {
1243
+ const writes = [];
1244
+ const fakeBoxes = {
1245
+ async getBoxByBuilderId() { return { state: 'active' }; }, // never reached 'destroyed'
1246
+ async setBoxState(_db, _bid, state, patch) { writes.push({ state, patch }); return {}; },
1247
+ };
1248
+ await boxCli.markErrorUnlessDestroyed({}, fakeBoxes, 96, 'DO 500 before teardown');
1249
+ assert.equal(writes.length, 1, 'a not-destroyed box is parked at error, as before');
1250
+ assert.equal(writes[0].state, 'error');
1251
+ assert.match(writes[0].patch.error_note, /DO 500/);
1252
+ });
1253
+
1254
+ console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
1255
+ process.exit(failed === 0 ? 0 : 1);