@bongos/core 1.17.1 → 1.17.3

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 (233) hide show
  1. package/.bongos-core.json +521 -281
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/clients/bongos-client/README.md +1 -1
  17. package/clients/bongos-client/bongos-client.global.js +2 -0
  18. package/clients/bongos-client/index.cjs +2 -0
  19. package/clients/bongos-client/index.d.ts +4 -2
  20. package/clients/bongos-client/index.mjs +2 -0
  21. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  22. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  23. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  24. package/docs/adr/0020-security-model-public-stub.md +1 -1
  25. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  26. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  27. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  28. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  29. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  30. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  31. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  32. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  33. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  34. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  35. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  36. package/docs/adr/0057-container-cost-ledger.md +3 -3
  37. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  38. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  39. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  40. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  41. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  42. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  43. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  44. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  45. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  46. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  47. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  48. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  49. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  50. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  51. package/docs/adr/README.md +9 -6
  52. package/docs/api/openapi.json +65 -26
  53. package/docs/api-reference.md +3 -2
  54. package/docs/architecture.md +2 -2
  55. package/docs/branding-contract.md +5 -4
  56. package/docs/canonical-permissions.md +1 -1
  57. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  58. package/docs/file-map.md +18 -17
  59. package/docs/handoff-template.md +2 -2
  60. package/docs/modules-contract.md +6 -1
  61. package/docs/onboarding/diagrams/README.md +7 -7
  62. package/docs/onboarding/drachmae-and-karma.md +18 -18
  63. package/docs/onboarding/primer.md +9 -9
  64. package/docs/recipes/audit-replay.md +2 -2
  65. package/docs/recipes/builder-box-lifecycle.md +2 -2
  66. package/docs/recipes/claude-design-loop.md +1 -1
  67. package/docs/recipes/ops-gotchas.md +1 -1
  68. package/docs/recipes/private-npm-distribution.md +17 -0
  69. package/docs/recipes/staging-environment.md +1 -1
  70. package/docs/recipes/upgrading-the-core.md +55 -0
  71. package/docs/research/cursor-competitive-analysis.md +1 -1
  72. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  73. package/modules/discord/module.json +2 -1
  74. package/modules/economy/achievements.js +9 -1
  75. package/modules/economy/credits.js +10 -1
  76. package/modules/hall-ui/public/builders.js +137 -620
  77. package/modules/hall-ui/public/goal-inbox.js +232 -0
  78. package/modules/hall-ui/public/goals-page.js +4 -1
  79. package/modules/hall-ui/public/goals.html +8 -0
  80. package/modules/hall-ui/public/hall-kit.js +474 -0
  81. package/modules/hall-ui/public/hall-widgets.js +14 -5
  82. package/modules/hall-ui/public/index.html +3 -0
  83. package/modules/hall-ui/public/instances-panel.js +24 -3
  84. package/modules/hall-ui/public/new-project-wizard.js +7 -5
  85. package/modules/hall-ui/public/style.css +168 -0
  86. package/modules/hall-ui/public/work.css +194 -0
  87. package/modules/hall-ui/public/work.html +46 -374
  88. package/modules/hall-ui/public/work.js +137 -102
  89. package/modules/lifecycle/criterion-suggest.js +80 -0
  90. package/modules/lifecycle/db.js +42 -5
  91. package/modules/lifecycle/routes/goals.js +26 -0
  92. package/modules/lifecycle/routes/tasks.js +21 -0
  93. package/modules/provisioning/CLAUDE.md +15 -0
  94. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  95. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  96. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  97. package/modules/provisioning/module.json +17 -0
  98. package/modules/provisioning/onboard-plan.js +37 -0
  99. package/modules/provisioning/provisioning.js +539 -0
  100. package/modules/provisioning/rate-limit.js +35 -0
  101. package/modules/provisioning/routes/provisioning.js +607 -0
  102. package/modules/provisioning/tests/provisioning.mjs +232 -0
  103. package/modules/provisioning/ui/.gitkeep +0 -0
  104. package/modules/security/secret-scrub.js +10 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +7 -2
  107. package/scripts/discord/README.md +1 -1
  108. package/scripts/gds/claim.js +98 -48
  109. package/scripts/gds/claude-materialize.js +30 -9
  110. package/scripts/gds/cutover-codemod.js +241 -0
  111. package/scripts/gds/fitness.js +54 -0
  112. package/scripts/gds/init.js +59 -3
  113. package/scripts/gds/lint-infra-exec.js +70 -0
  114. package/scripts/gds/lint-workflow-exec.js +63 -0
  115. package/scripts/gds/mirror-lineage-check.js +14 -5
  116. package/scripts/gds/mirror-redact.js +8 -0
  117. package/scripts/gds/onboard.js +4 -0
  118. package/scripts/gds/package-core.js +18 -3
  119. package/scripts/gds/publish-manifest.js +60 -2
  120. package/scripts/gds/run-routine.js +51 -13
  121. package/scripts/gds/run-unit-tests.js +4 -0
  122. package/scripts/gds/update-channel.js +186 -0
  123. package/scripts/gds/upgrade.js +116 -9
  124. package/scripts/hall-preview/README.md +103 -0
  125. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  126. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  127. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  128. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  129. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  130. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  131. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  132. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  133. package/scripts/hall-preview/fixtures/goals.json +502 -0
  134. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  135. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  136. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  137. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  138. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  139. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  140. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  141. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  142. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  143. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  144. package/scripts/hall-preview/fixtures/instance.json +124 -0
  145. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  146. package/scripts/hall-preview/fixtures/me.json +476 -0
  147. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  148. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  149. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  150. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  151. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  152. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  153. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  154. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  155. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  156. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  157. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  158. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  159. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  160. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  161. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  162. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  163. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  164. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  165. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  166. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  167. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  168. package/scripts/hall-preview/fixtures/versions.json +89 -0
  169. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  170. package/scripts/hall-preview/server.js +178 -0
  171. package/scripts/migrate.sh +29 -4
  172. package/src/bongos/auth.js +14 -1
  173. package/src/bongos/optimizer.js +37 -2
  174. package/src/bongos/serve-internal.js +10 -1
  175. package/src/branding.js +1 -0
  176. package/src/module-api.js +12 -1
  177. package/src/module-loader/loader.js +36 -4
  178. package/src/module-loader/manifest-schema.js +16 -1
  179. package/tests/branding.mjs +11 -0
  180. package/tests/claim_error_surface.mjs +107 -0
  181. package/tests/claude_materialize.mjs +20 -0
  182. package/tests/consumer_layout_boot.mjs +302 -0
  183. package/tests/criterion_suggest.mjs +106 -0
  184. package/tests/cutover_codemod.mjs +184 -0
  185. package/tests/device_flow_start.mjs +25 -2
  186. package/tests/hook_core_resolve.mjs +125 -0
  187. package/tests/infra_exec_paths.mjs +104 -0
  188. package/tests/init.mjs +47 -0
  189. package/tests/migrate_root_resolution.mjs +97 -0
  190. package/tests/mirror_lineage_proof.mjs +14 -0
  191. package/tests/module_loader.mjs +42 -0
  192. package/tests/module_manifest.mjs +19 -0
  193. package/tests/onboard.mjs +13 -0
  194. package/tests/package_core.mjs +8 -0
  195. package/tests/provisioning_github_app_routes.mjs +15 -0
  196. package/tests/publish_manifest.mjs +52 -1
  197. package/tests/run_routine.mjs +26 -1
  198. package/tests/run_routine_two_root.mjs +77 -0
  199. package/tests/scrubber_corpus.mjs +17 -0
  200. package/tests/update_channel.mjs +186 -0
  201. package/tests/upgrade.mjs +131 -0
  202. package/tests/workflow_exec_paths.mjs +98 -0
  203. package/docs/design/container-exterior-tiles.md +0 -49
  204. package/docs/design/container-exterior.md +0 -81
  205. package/docs/design/container-interior.md +0 -77
  206. package/docs/design/world-map-scroll.md +0 -41
  207. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  208. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  209. package/docs/recipes/cloudbongos-standup.md +0 -111
  210. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  211. package/docs/recipes/parallel-load-test.md +0 -32
  212. package/docs/recipes/pixel-art-pipeline.md +0 -411
  213. package/docs/recipes/staging-data.md +0 -50
  214. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  215. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  216. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  217. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  218. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  219. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  220. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  221. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  222. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  223. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  224. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  225. package/scripts/gds/art-stage.js +0 -152
  226. package/server.js +0 -212
  227. package/tests/art_key.mjs +0 -121
  228. package/tests/day_cycle.mjs +0 -197
  229. package/tests/snapshot_picker.mjs +0 -233
  230. package/tests/sprite_facing.mjs +0 -35
  231. package/tests/terrain_picker.mjs +0 -298
  232. package/tests/turn_in_place.mjs +0 -280
  233. package/tests/walkability.mjs +0 -188
@@ -44,7 +44,9 @@ const PUBLISH_ALLOWLIST = [
44
44
  'src/', // platform engine + GDS core (the game residue is denied below)
45
45
  'scripts/', // build + methodology tooling (deploy is denied below)
46
46
  'bin/', // the `cloudbongos`/`bongos` CLI entrypoint
47
- 'server.js', // the single-process app entry
47
+ // NB: repo-root server.js is the GAME+platform entry (top-level require('colyseus'),
48
+ // a pruned core dep) — HOST-excluded in CONTENT_EXCLUDE below; the core boots
49
+ // game-less via src/platform-server.js (package-core sets that as `main`). task 2134.
48
50
  // --- the build system's shared schema + its tests ---
49
51
  'migrations/',
50
52
  'tests/',
@@ -63,6 +65,7 @@ const PUBLISH_ALLOWLIST = [
63
65
  'modules/status-ui/',
64
66
  'modules/dev-box/',
65
67
  'modules/discord/',
68
+ 'modules/provisioning/', // instance-hosting control plane — CORE like its sibling dev-box (same do-api/cf-tunnel toolchain, all core). Reclassified IN so the core CLI scripts/gds/provision.js resolves it from the core package post-split, not a MODULE_NOT_FOUND (task 2135). default:false — shipped ≠ enabled.
66
69
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
67
70
  'config/branding.neutral.json',
68
71
  'config/modules.neutral.json',
@@ -77,7 +80,7 @@ const PUBLISH_ALLOWLIST = [
77
80
  'CONTRIBUTING.md',
78
81
  'CODE_OF_CONDUCT.md',
79
82
  'docs/adr/', // sanitized — ADR 0065 §4 "docs/ADRs (sanitized)"
80
- 'docs/recipes/',
83
+ 'docs/recipes/', // portable METHODOLOGY recipes only; project-specific ones (art/staging/load-test/standup/decouple) are denied below — task 2153
81
84
  'docs/design/',
82
85
  'docs/research/',
83
86
  'docs/architecture.md',
@@ -85,6 +88,11 @@ const PUBLISH_ALLOWLIST = [
85
88
  // renders broken onboarding + API-reference pages (task 2078). Redacted like the
86
89
  // rest of this docs section; the diagrams' .png/.svg regenerate on the box.
87
90
  'docs/onboarding/', // primer.md + drachmae-and-karma.md + diagram sources (/builders/primer · /drachmae · /diagrams)
91
+ // API artifacts (classification settled, task 2141): CORE-shipped STARTERS so a fresh
92
+ // hall/docs render before first regen — but per-instance GENERATED. init + `bongos
93
+ // upgrade` regenerate them INTO the instance; the instance-root fitness gate
94
+ // (gen-api-docs --check) + tests/api_client.mjs guard drift. They ship; they are not a
95
+ // monolith-only artifact.
88
96
  'docs/api/openapi.json', // the generated OpenAPI 3.1 spec served at /docs/openapi.json
89
97
  'docs/api-reference.md', // the markdown API reference
90
98
  'docs/modules-contract.md',
@@ -161,12 +169,33 @@ const CONTENT_EXCLUDE = [
161
169
  'src/world/',
162
170
  'src/rooms/',
163
171
  'src/preview-server.js',
172
+ 'server.js', // repo-root game+platform entry: top-level require('colyseus') (a pruned core dep) → un-runnable in the game-less package; the core boots via src/platform-server.js. Mirrors src/preview-server.js. (task 2134)
164
173
  // the art feature's CLI tooling lives under scripts/ (legacy top-level) but belongs
165
174
  // to the excluded art module — exclude it with the feature it serves.
166
175
  'scripts/art/',
176
+ 'scripts/gds/art-stage.js', // art sandbox-staging CLI: requires modules/art-pipeline's build_tilesheet.py; excluded with the art feature it serves so a core file never requires an excluded module (task 2135)
167
177
  'scripts/tile-add.js',
168
178
  'scripts/tile-find.js',
169
179
  'scripts/tile-recommend.js',
180
+ // The art pipeline's operational RECIPE — belongs to the excluded art module; exclude it
181
+ // with the feature it serves (mirrors scripts/art/ above). docs/recipes/ is allowlisted
182
+ // wholesale, so this deny is what keeps the game-art recipe out of a non-art core. (task 2153)
183
+ 'docs/recipes/pixel-art-pipeline.md',
184
+ // Host-feature TESTS that statically import an EXCLUDED module's source. tests/ is
185
+ // allowlisted (the platform/methodology suite is core), but these cover the game/art
186
+ // HOST modules and import their source directly — so package-core (which selects via
187
+ // isPublishable) would ship them WITHOUT the module they need → MODULE_NOT_FOUND when a
188
+ // core CI/consumer runs them, and the R89 cutover would DELETE OTB's own game/art tests
189
+ // (they cover kept host modules; they cannot live in core). Exclude each with the feature
190
+ // it serves — mirrors scripts/gds/art-stage.js above. The guard in tests/publish_manifest.mjs
191
+ // derives this set from disk and fails if a new host-module test is added but not listed. (task 2147)
192
+ 'tests/walkability.mjs', // → modules/game/world
193
+ 'tests/terrain_picker.mjs', // → modules/game/public/game/world
194
+ 'tests/snapshot_picker.mjs', // → modules/game/public/game/world
195
+ 'tests/sprite_facing.mjs', // → modules/game/public/game/player
196
+ 'tests/turn_in_place.mjs', // → modules/game/public/game/player
197
+ 'tests/day_cycle.mjs', // → modules/game/public/game/fx
198
+ 'tests/art_key.mjs', // → modules/art-pipeline
170
199
  ];
171
200
 
172
201
  // (4) INSTANCE IDENTITY + HISTORY — this host's literals, ops config, build history, and
@@ -178,6 +207,11 @@ const INSTANCE_EXCLUDE = [
178
207
  'config/devbox-app.json',
179
208
  'config/hierarchy.json',
180
209
  'CODEOWNERS', // names the owner (a protected surface, excluded as identity)
210
+ // instance SEED migrations — this host's scope-truth SQL (planning seeds, world data).
211
+ // ADR 0105: a consumer's core must not carry them; migrate.sh reads instance seeds from
212
+ // resolveInstanceRoot() (SEED_INSTANCE), so the consumer repo owns them. Core migrations
213
+ // (migrations/core_NNN_*.sql + bare legacy migrations/NNN_*.sql) still publish. (task 2140)
214
+ 'migrations/instance/',
181
215
  // instance-specific docs + project history
182
216
  'docs/project-context.md',
183
217
  'docs/lore/',
@@ -192,6 +226,30 @@ const INSTANCE_EXCLUDE = [
192
226
  'docs/graphics-stress-test-plan.md',
193
227
  'docs/security-baseline.md',
194
228
  'docs/routes-permissions.md',
229
+ // instance-specific OPERATIONAL recipes — this host's staging + load-test artifacts, its
230
+ // specific control-plane / vanity-door standups, and its one-time monolith-decouple runbook.
231
+ // docs/recipes/ is allowlisted wholesale for the PORTABLE methodology recipes; these teach
232
+ // nothing a different instance would use, so they stay in this repo and never ship in the
233
+ // core (mirrors migrations/instance above). Redaction only de-brands them — this stops the
234
+ // irrelevant-noise at the source. The module-owned RECIPE redesign is deferred (idea 536). (task 2153)
235
+ 'docs/recipes/staging-data.md',
236
+ 'docs/recipes/parallel-load-test.md',
237
+ 'docs/recipes/parallel-load-results-2026-05-30.md',
238
+ 'docs/recipes/claude-gemini-bongos-doors.md',
239
+ 'docs/recipes/cloudbongos-standup.md',
240
+ 'docs/recipes/cloudbongos-staging-gate.md',
241
+ 'docs/recipes/amazonprimea-decouple-r89.md',
242
+ 'docs/recipes/amazonprimea-decouple-rollback-r89.md',
243
+ // instance-specific GAME-DESIGN specs — this host's pixel-art / level briefs (the container
244
+ // buildings, its palette, the world-map scroll). docs/design/ is allowlisted wholesale for the
245
+ // PORTABLE design docs (modular-architecture/*, the vanilla-hall-ui redesign scope); these four
246
+ // describe THIS instance's game and genericize to nothing, so they stay in this repo and never
247
+ // ship in the core. Redaction only de-brands NAMES — the world-narrative prose in them is not a
248
+ // name and survives redaction, so exclude at the source (mirrors the recipe carve-out above). (task 2155)
249
+ 'docs/design/container-exterior.md',
250
+ 'docs/design/container-exterior-tiles.md',
251
+ 'docs/design/container-interior.md',
252
+ 'docs/design/world-map-scroll.md',
195
253
  'blockers/',
196
254
  'limitations/',
197
255
  'generative-ideas.md',
@@ -23,9 +23,20 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
  const { execFileSync } = require('child_process');
26
-
27
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
28
- const TASKS_DIR = path.join(REPO_ROOT, '.claude', 'scheduled-tasks');
26
+ const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config');
27
+
28
+ // TWO roots (ADR 0108 configurable-root; task 2138). run-routine.js is a CORE script, so it
29
+ // ships alongside src/, scripts/gds/*, scripts/discord/*, and modules/ — all under
30
+ // resolveCoreRoot(), which is where core step scripts + the grader resolve from. The ROUTINES
31
+ // themselves are TOPOLOGY: `bongos init/upgrade` materializes .claude/scheduled-tasks/ INTO the
32
+ // instance (claude-materialize.js), so they're found under resolveInstanceRoot(). Today both
33
+ // roots are the one checkout — BYTE-IDENTICAL to the old `path.resolve(__dirname,'..','..')`;
34
+ // post-split TASKS_DIR points at the instance repo while core scripts resolve inside
35
+ // node_modules/@bongos/core. (Do NOT reintroduce a hardcoded __dirname/../.. — see scripts/gds/CLAUDE.md.)
36
+ const CORE_ROOT = resolveCoreRoot();
37
+ const INSTANCE_ROOT = resolveInstanceRoot();
38
+ const TASKS_DIR = path.join(INSTANCE_ROOT, '.claude', 'scheduled-tasks');
39
+ const DISCORD_POST = path.join(CORE_ROOT, 'scripts', 'discord', 'post.js');
29
40
  const VALID_MODES = new Set(['deterministic', 'hybrid', 'judgment']);
30
41
  const VALID_SINKS = new Set(['discord', 'gds', 'none']);
31
42
  const ALERT_CHANNEL = process.env.OTB_ROUTINE_ALERT_CHANNEL || 'ship_feed';
@@ -104,10 +115,36 @@ function tokenize(cmd) {
104
115
  return out;
105
116
  }
106
117
 
118
+ function isNodeExec(a) {
119
+ const base = path.basename(String(a || '')).toLowerCase();
120
+ return a === process.execPath || base === 'node' || base === 'node.exe';
121
+ }
122
+
123
+ // Pure: resolve a routine step's argv so it runs in BOTH layouts (ADR 0108; task 2138).
124
+ // • `bongos exec <rel> [args]` (the materialized invocation form) → run <rel> under the CORE
125
+ // root, exactly like `bongos exec` itself does (bin/bongos.js resolveExecTarget).
126
+ // • `node <path> [args]` → resolve <path>: a `scripts/...` core path under coreRoot; any other
127
+ // relative path (notably the `.claude/scheduled-tasks/<name>/<x>.js` companions) under the
128
+ // instance root. Absolute paths and non-node commands pass through untouched.
129
+ // Commands come from committed SKILL.md (trusted), so this is path resolution, not sandboxing.
130
+ // Today coreRoot === instanceRoot (single checkout) so this is byte-identical to the old run;
131
+ // only after the split do the two roots diverge. Exported + unit-tested.
132
+ function resolveStepArgv(argv, { coreRoot = CORE_ROOT, instanceRoot = INSTANCE_ROOT } = {}) {
133
+ if (!argv || !argv.length) return argv || [];
134
+ if (argv[0] === 'bongos' && argv[1] === 'exec' && argv[2]) {
135
+ return [process.execPath, path.join(coreRoot, argv[2]), ...argv.slice(3)];
136
+ }
137
+ if (isNodeExec(argv[0]) && argv[1] && !path.isAbsolute(argv[1])) {
138
+ const base = argv[1].startsWith('scripts/') ? coreRoot : instanceRoot;
139
+ return [process.execPath, path.join(base, argv[1]), ...argv.slice(2)];
140
+ }
141
+ return argv;
142
+ }
143
+
107
144
  function opsAlert(message) {
108
145
  try {
109
- execFileSync(process.execPath, [path.join('scripts', 'discord', 'post.js'), '--channel', ALERT_CHANNEL, String(message).slice(0, 1800)],
110
- { cwd: REPO_ROOT, stdio: 'ignore' });
146
+ execFileSync(process.execPath, [DISCORD_POST, '--channel', ALERT_CHANNEL, String(message).slice(0, 1800)],
147
+ { cwd: INSTANCE_ROOT, stdio: 'ignore' });
111
148
  } catch (_) { /* best-effort: the alert must never mask the real failure */ }
112
149
  }
113
150
 
@@ -116,19 +153,20 @@ function routeOutput(sink, stdout, routineName) {
116
153
  if (text) console.log(text);
117
154
  if (sink === 'discord' && text) {
118
155
  try {
119
- execFileSync(process.execPath, [path.join('scripts', 'discord', 'post.js'), '--channel', ALERT_CHANNEL, `[${routineName}] ${text}`.slice(0, 1800)],
120
- { cwd: REPO_ROOT, stdio: 'ignore' });
156
+ execFileSync(process.execPath, [DISCORD_POST, '--channel', ALERT_CHANNEL, `[${routineName}] ${text}`.slice(0, 1800)],
157
+ { cwd: INSTANCE_ROOT, stdio: 'ignore' });
121
158
  } catch (_) { /* best-effort */ }
122
159
  }
123
160
  // sink 'gds' / 'none': the step's command performed its own GDS side effects.
124
161
  }
125
162
 
126
163
  function runStep(step, routineName, { dryRun }) {
127
- const argv = tokenize(step.command);
128
- if (!argv.length) return '';
164
+ const tokens = tokenize(step.command);
165
+ if (!tokens.length) return '';
129
166
  if (dryRun) { console.log(`[dry-run] would run (${step.sink}): ${step.command}`); return ''; }
167
+ const argv = resolveStepArgv(tokens);
130
168
  const out = execFileSync(argv[0], argv.slice(1), {
131
- cwd: REPO_ROOT,
169
+ cwd: INSTANCE_ROOT, // routines run in the instance repo (companions + git-touching steps)
132
170
  encoding: 'utf8',
133
171
  env: { ...process.env, OTB_COST_CRON_ROUTINE: routineName },
134
172
  });
@@ -140,7 +178,7 @@ function runJudgment(routineName, skillBody, { dryRun }) {
140
178
  if (dryRun) { console.log(`[dry-run] judgment mode → would spawn claude -p over ${routineName}/SKILL.md`); return; }
141
179
  // Status quo: a wrapped session reading the SKILL body. Reuse the shared spawn.
142
180
  const grader = require('../../modules/grading/grader');
143
- return grader.runSubagent({ prompt: skillBody, opts: { cwd: REPO_ROOT } });
181
+ return grader.runSubagent({ prompt: skillBody, opts: { cwd: INSTANCE_ROOT } });
144
182
  }
145
183
 
146
184
  async function main() {
@@ -176,7 +214,7 @@ async function main() {
176
214
  try { const j = JSON.parse(String(out).trim().split('\n').pop() || '{}'); if (j && j.needs_judgment) payload = j.needs_judgment; } catch (_) {}
177
215
  if (payload) {
178
216
  const grader = require('../../modules/grading/grader');
179
- await grader.runSubagent({ prompt: String(payload), opts: { cwd: REPO_ROOT } });
217
+ await grader.runSubagent({ prompt: String(payload), opts: { cwd: INSTANCE_ROOT } });
180
218
  }
181
219
  }
182
220
  }
@@ -194,4 +232,4 @@ if (require.main === module) {
194
232
  main();
195
233
  }
196
234
 
197
- module.exports = { parseRoutineFrontmatter, tokenize, VALID_MODES, VALID_SINKS };
235
+ module.exports = { parseRoutineFrontmatter, tokenize, resolveStepArgv, isNodeExec, VALID_MODES, VALID_SINKS };
@@ -65,6 +65,10 @@ const SUPPORT_MODULES = new Set(['helpers']);
65
65
  const INTEGRATION = new Set([
66
66
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
67
67
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
68
+ // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
69
+ // platform-server — network + package build + a spawned server, run by its own
70
+ // .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
71
+ 'consumer_layout_boot',
68
72
  'discord_autojoin', 'discord_oauth', 'linkify_refs', 'memory_isolation',
69
73
  'memory_pull', 'parallel-load', 'session_records', 'setup_smoke',
70
74
  'status-probe', 'subdomain_auth',
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/update-channel.js — the update-subscription POLICY core (task 2150, ADR 0136).
5
+ //
6
+ // "How does a consumer instance subscribe to regular @bongos/core updates?" The owner-chosen
7
+ // answer (2026-07-09): **patch-only by default, health-gated, auto-rollback, opt-in, autonomy-gated.**
8
+ // This module is the PURE, testable heart of that policy — the semver + channel math + the
9
+ // subscription-manifest loader. It launches nothing and mutates nothing; the scheduled routine
10
+ // (.claude/scheduled-tasks/core-update-subscription/subscribe.js) drives the actual `bongos upgrade`
11
+ // per subscribed instance using what this module resolves.
12
+ //
13
+ // A per-instance UPDATE CHANNEL bounds how far an unattended upgrade may jump:
14
+ // pinned — never auto-upgrade (manual `bongos upgrade` only).
15
+ // patch — take a higher PATCH within the same major.minor (1.17.1 → 1.17.4, never 1.18.0). DEFAULT.
16
+ // minor — take a higher MINOR within the same major (1.17.1 → 1.18.0, never 2.0.0).
17
+ // A MAJOR bump is NEVER automatic under any channel — a major is a breaking change that wants a human.
18
+ // Prereleases (1.17.2-rc.1) are never auto-target either — only stable releases.
19
+ //
20
+ // WHY the registry as the source (not vendored tarballs): a subscription needs a LIVE source of
21
+ // truth for "what's the newest published core?" — that's `npm view @bongos/core versions` against the
22
+ // private registry (ADR 0134, task 2090). The vendored path is for a one-off manual bump.
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const { spawnSync } = require('node:child_process');
27
+
28
+ const CORE_PKG = '@bongos/core';
29
+ const VALID_CHANNELS = ['pinned', 'patch', 'minor'];
30
+ const DEFAULT_CHANNEL = 'patch';
31
+ // The subscription manifest an operator edits to opt instances in. Ships EMPTY (instances: []) in the
32
+ // core template — a fresh instance subscribes nothing until the operator lists a target here.
33
+ const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
34
+
35
+ // ---- semver (stable x.y.z, optional prerelease tag) -------------------------
36
+ // A deliberately small parser — the repo avoids an npm-semver dependency in the CLI path (see
37
+ // upgrade.js versionFromDep). We only need compare + the major/minor/patch fields.
38
+
39
+ // Parse "1.17.2" / "v1.17.2" / "1.17.2-rc.1" → { major, minor, patch, pre } or null. `pre` is the
40
+ // prerelease tag ('' for a plain stable release), used only to EXCLUDE prereleases from auto-targets.
41
+ function parseSemver(v) {
42
+ const m = String(v == null ? '' : v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
43
+ if (!m) return null;
44
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
45
+ }
46
+
47
+ function isStable(v) {
48
+ const p = parseSemver(v);
49
+ return !!p && p.pre === '';
50
+ }
51
+
52
+ // Compare two version strings by (major, minor, patch). Returns -1 / 0 / 1. A stable release sorts
53
+ // ABOVE its own prereleases (1.17.2 > 1.17.2-rc.1), matching semver precedence — enough for "is this
54
+ // candidate newer than what's installed?" Unparseable inputs sort as -Infinity (never a target).
55
+ function compareSemver(a, b) {
56
+ const pa = parseSemver(a);
57
+ const pb = parseSemver(b);
58
+ if (!pa && !pb) return 0;
59
+ if (!pa) return -1;
60
+ if (!pb) return 1;
61
+ for (const k of ['major', 'minor', 'patch']) {
62
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
63
+ }
64
+ // Same x.y.z: a prerelease is LOWER than the stable release. (Two prereleases: not ordered
65
+ // precisely here — we never auto-target a prerelease, so their relative order is immaterial.)
66
+ if (pa.pre && !pb.pre) return -1;
67
+ if (!pa.pre && pb.pre) return 1;
68
+ return 0;
69
+ }
70
+
71
+ function normalizeChannel(channel) {
72
+ const c = String(channel == null ? '' : channel).trim().toLowerCase();
73
+ return VALID_CHANNELS.includes(c) ? c : DEFAULT_CHANNEL;
74
+ }
75
+
76
+ // Would `channel` permit an unattended jump from `installed` to `candidate`? True only when candidate
77
+ // is a STABLE release strictly newer than installed AND within the channel's blast radius. Any invalid
78
+ // input, an unknown installed version, a prerelease candidate, or a major bump ⇒ false (fail-closed).
79
+ function channelAllows(installed, candidate, channel) {
80
+ const ch = normalizeChannel(channel);
81
+ if (ch === 'pinned') return false;
82
+ const from = parseSemver(installed);
83
+ const to = parseSemver(candidate);
84
+ if (!from || !to) return false;
85
+ if (to.pre) return false; // never auto-target a prerelease
86
+ if (compareSemver(candidate, installed) <= 0) return false; // must be strictly newer
87
+ if (to.major !== from.major) return false; // a major bump is never automatic
88
+ if (ch === 'patch' && to.minor !== from.minor) return false; // patch stays on the same minor
89
+ return true; // 'minor' allows any higher minor within the major
90
+ }
91
+
92
+ // The highest version in `available` that `channel` permits from `installed`, or null if none
93
+ // (nothing newer, pinned, only prereleases/majors ahead, or an unknown installed version). This is
94
+ // the one function the routine asks: "given where this instance is and what it subscribes to, what —
95
+ // if anything — should I upgrade it to right now?"
96
+ function resolveChannelTarget({ installed, channel, available } = {}) {
97
+ const list = Array.isArray(available) ? available : [];
98
+ let best = null;
99
+ for (const v of list) {
100
+ if (!channelAllows(installed, v, channel)) continue;
101
+ if (best === null || compareSemver(v, best) > 0) best = v;
102
+ }
103
+ return best;
104
+ }
105
+
106
+ // ---- registry: what versions of the core are published? ---------------------
107
+
108
+ // List published versions of @bongos/core from the registry via `npm view … versions --json`
109
+ // (ADR 0134 — the private scoped package resolves through the instance's env-fed .npmrc, so run this
110
+ // with cwd inside an instance that has one + NPM_TOKEN in env). Fail-SOFT: any error returns
111
+ // { ok:false } so the routine skips that instance rather than crashing the whole sweep. `run` +
112
+ // `pkg` are injectable for tests.
113
+ function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env } = {}) {
114
+ let res;
115
+ try {
116
+ res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8' });
117
+ } catch (e) {
118
+ return { ok: false, error: e && e.message ? e.message : String(e), versions: [] };
119
+ }
120
+ if (!res || res.status !== 0) {
121
+ const reason = (res && (res.stderr || res.error && res.error.message)) || `exit ${res && res.status}`;
122
+ return { ok: false, error: String(reason).trim().split('\n')[0] || 'npm view failed', versions: [] };
123
+ }
124
+ return parseNpmViewVersions(res.stdout);
125
+ }
126
+
127
+ // Parse `npm view … versions --json` stdout. npm prints a JSON ARRAY for a multi-version package, a
128
+ // bare JSON STRING for a single-version one, and nothing for an unpublished name. Normalizes all
129
+ // three to { ok:true, versions:[…] }. Exported + unit-tested (the shape has bitten callers before).
130
+ function parseNpmViewVersions(stdout) {
131
+ const s = String(stdout == null ? '' : stdout).trim();
132
+ if (!s) return { ok: true, versions: [] };
133
+ let parsed;
134
+ try { parsed = JSON.parse(s); } catch (e) { return { ok: false, error: `unparseable npm output: ${e.message}`, versions: [] }; }
135
+ if (Array.isArray(parsed)) return { ok: true, versions: parsed.map(String) };
136
+ if (typeof parsed === 'string') return { ok: true, versions: [parsed] };
137
+ return { ok: false, error: 'unexpected npm view shape', versions: [] };
138
+ }
139
+
140
+ // ---- subscription manifest --------------------------------------------------
141
+
142
+ // Read + normalize config/update-subscriptions.json — the operator-edited opt-in list of instances
143
+ // this control plane keeps up to date. Never throws: a missing/malformed file ⇒ an empty roster
144
+ // (subscribe nothing), because an unreadable manifest must not upgrade anything. Each entry is
145
+ // normalized to { slug, dir, service, healthUrl, channel } with the channel validated + defaulted.
146
+ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SUBSCRIPTIONS_REL } = {}) {
147
+ const abs = path.isAbsolute(file) ? file : path.join(instanceDir, file);
148
+ let raw;
149
+ try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false }; }
150
+ let json;
151
+ try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, error: `malformed JSON: ${e.message}` }; }
152
+ const defaultChannel = normalizeChannel(json.defaultChannel);
153
+ const rows = Array.isArray(json.instances) ? json.instances : [];
154
+ const instances = rows
155
+ .filter((r) => r && typeof r === 'object' && r.dir) // an entry without a dir can't be upgraded — drop it
156
+ .map((r) => ({
157
+ slug: String(r.slug || r.dir),
158
+ dir: String(r.dir),
159
+ service: r.service ? String(r.service) : null,
160
+ healthUrl: r.healthUrl ? String(r.healthUrl) : null,
161
+ channel: normalizeChannel(r.channel == null ? defaultChannel : r.channel),
162
+ // Per-instance env merged into the `bongos upgrade` child (e.g. PGDATABASE so the migrate step
163
+ // hits THIS instance's DB, not the control plane's — see the live-op learnings for demo). Only
164
+ // string values are kept; anything else is ignored.
165
+ env: (r.env && typeof r.env === 'object' && !Array.isArray(r.env))
166
+ ? Object.fromEntries(Object.entries(r.env).filter(([, v]) => typeof v === 'string'))
167
+ : {},
168
+ }));
169
+ return { defaultChannel, instances, source: abs, present: true };
170
+ }
171
+
172
+ module.exports = {
173
+ CORE_PKG,
174
+ VALID_CHANNELS,
175
+ DEFAULT_CHANNEL,
176
+ SUBSCRIPTIONS_REL,
177
+ parseSemver,
178
+ isStable,
179
+ compareSemver,
180
+ normalizeChannel,
181
+ channelAllows,
182
+ resolveChannelTarget,
183
+ listAvailableVersions,
184
+ parseNpmViewVersions,
185
+ loadSubscriptions,
186
+ };
@@ -100,6 +100,21 @@ function writeRegistryPin(instanceDir, version, fsImpl = fs) {
100
100
  return prev;
101
101
  }
102
102
 
103
+ // Restore the consumer package.json's @bongos/core pin to an EXACT previous dep string,
104
+ // VERBATIM — whether that was a `file:` tarball path (vendored) or a bare registry semver.
105
+ // The inverse of writePin/writeRegistryPin used by the auto-rollback path (task 2149), which
106
+ // snapshots the returned prev-dep before the bump. Returns the dep string it overwrote.
107
+ function restorePin(instanceDir, depString, fsImpl = fs) {
108
+ const pkgPath = path.join(instanceDir, 'package.json');
109
+ const pkg = readJson(pkgPath, fsImpl);
110
+ if (!pkg) throw new Error(`no readable package.json at ${pkgPath}`);
111
+ pkg.dependencies = pkg.dependencies || {};
112
+ const prev = pkg.dependencies[CORE_PKG];
113
+ pkg.dependencies[CORE_PKG] = depString;
114
+ fsImpl.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
115
+ return prev;
116
+ }
117
+
103
118
  // ---- steps (each injectable for tests) --------------------------------------
104
119
 
105
120
  function gitTreeClean(instanceDir, run = spawnSync) {
@@ -154,10 +169,11 @@ function runMigrate(instanceDir, run = spawnSync) {
154
169
 
155
170
  // Regenerate the per-instance API artifacts (OpenAPI spec + api-reference + typed
156
171
  // client) from the NEW core's routes — the claude-materialize sibling (task 2051).
157
- // WHY: the routes + generators ship WITH core, but the generated spec/docs/client are
158
- // EXCLUDED from the package (per-instance artifacts), so a bump changes the served API
159
- // while the instance's committed spec/client lag until the next ship's fitness gate.
160
- // This closes that gap on upgrade. We SPAWN the new core's generators (a fresh child
172
+ // WHY: the routes + generators ship WITH core, and the generated spec/docs/client also
173
+ // ship as CORE STARTERS (task 2078 — a fresh hall/docs must render before first regen),
174
+ // but they are per-instance GENERATED, so a bump changes the served API while the
175
+ // instance's committed spec/client lag until regenerated (the classification settled in
176
+ // task 2141; init does the same at standup). We SPAWN the new core's generators (a fresh child
161
177
  // captures its output root from the env below) with <FALLBACK_PREFIX>_INSTANCE_ROOT set
162
178
  // so resolveDocsRoot() writes INTO the instance repo, not node_modules. docs first — the
163
179
  // client reads the spec it produces. Best-effort: a hiccup warns, never fails the bump
@@ -279,6 +295,64 @@ async function pollHealth(healthUrl, deps = {}) {
279
295
  }
280
296
  }
281
297
 
298
+ // Auto-rollback (task 2149): a bump failed AFTER the pin was moved — an install or migrate
299
+ // error, or a failed post-restart health check. Revert the code pin to the pre-upgrade
300
+ // snapshot, reinstall the previous core, restart, re-verify health, and record the reversal
301
+ // in the ledger. The DB is deliberately NOT rolled back: core migrations are additive +
302
+ // forward-safe (the core migration rule), so any already-applied migration is left in place —
303
+ // only the code PIN moves back. Best-effort + fail-soft: every sub-step logs but never throws,
304
+ // so a rollback attempt can't itself crash the upgrader on top of the original failure.
305
+ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts }, reason, deps = {}) {
306
+ const log = deps.log || console.log;
307
+ const err = deps.err || console.error;
308
+ const run = deps.run || spawnSync;
309
+ const fsImpl = deps.fs || fs;
310
+ const restore = deps.restorePin || restorePin;
311
+
312
+ err(` ↩ rolling back: ${reason} — reverting ${CORE_PKG} ${toVersion} → ${snapshot.prevVersion || snapshot.prevPin}`);
313
+ const result = { rolledBack: true, reason, restoredPin: snapshot.prevPin, restoredVersion: snapshot.prevVersion };
314
+
315
+ // 1. revert the pin to the exact previous dep string.
316
+ try { restore(instanceDir, snapshot.prevPin, fsImpl); result.restoreOk = true; log(` ✓ restored pin → ${snapshot.prevPin}`); }
317
+ catch (e) { result.restoreOk = false; err(` ! could not restore pin (${e.message}) — package.json may need manual repair`); }
318
+
319
+ // 2. reinstall the previous core.
320
+ const inst = npmInstall(instanceDir, run);
321
+ result.reinstallOk = !!inst.ok;
322
+ if (inst.ok) log(' ✓ reinstalled previous core');
323
+ else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
324
+
325
+ // 3. restart back onto the previous core.
326
+ const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run);
327
+ if (rs.skipped) log(' • restart: no --service/--restart-cmd — restart the instance service manually to load the reverted core.');
328
+ else if (!rs.ok) err(` ! restart after rollback failed (exit ${rs.code}) — restart the service manually.`);
329
+ else log(` ✓ restarted ${opts.service || 'service'} on the previous core`);
330
+ result.restartOk = rs.ok;
331
+
332
+ // 4. re-verify: installed == previous, optional health recovery.
333
+ const installedNow = readInstalledCoreVersion(instanceDir, fsImpl);
334
+ result.installedAfter = installedNow;
335
+ log(` ${installedNow === snapshot.prevVersion ? '✓' : '!'} installed core after rollback: ${installedNow || '(unknown)'}`);
336
+ if (opts.healthUrl) {
337
+ const h = await pollHealth(opts.healthUrl, deps);
338
+ result.healthOk = h.ok;
339
+ if (h.ok) log(` ✓ health recovered ${opts.healthUrl} → ${h.status}${h.attempts > 1 ? ` (after ${h.attempts} tries)` : ''}`);
340
+ else err(` ! health STILL failing after rollback (${h.error || 'status ' + h.status}) — manual intervention needed`);
341
+ }
342
+
343
+ // 5. record the reversal in the ledger via the always-present `note` column (no schema
344
+ // dependency — so it records even when a FAILED migrate means a newer column wouldn't exist).
345
+ const note = `rolled_back: ${reason} (attempted ${fromVersion || '(none)'} → ${toVersion}, restored ${snapshot.prevVersion || 'previous'})`;
346
+ const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note }, deps);
347
+ if (ledger.ok) log(' ✓ recorded rollback in core_upgrades ledger');
348
+ else if (ledger.skipped) log(` • rollback ledger skipped (${ledger.reason})`);
349
+ else err(` ! rollback ledger insert failed (${ledger.reason}) — record it manually`);
350
+ result.ledger = ledger.ok;
351
+
352
+ err(` ↩ rollback complete — instance restored to core ${snapshot.prevVersion || snapshot.prevPin}.`);
353
+ return result;
354
+ }
355
+
282
356
  // ---- orchestration ----------------------------------------------------------
283
357
 
284
358
  async function runUpgrade(opts, deps = {}) {
@@ -291,9 +365,16 @@ async function runUpgrade(opts, deps = {}) {
291
365
  const instanceDir = path.resolve(opts.instance || process.cwd());
292
366
  const targetVersion = opts.to;
293
367
  const dryRun = !!opts.dryRun;
368
+ // Auto-rollback is ON by default (task 2149); `--no-rollback-on-failure` opts out (e.g. an
369
+ // interactive run that wants to inspect a broken state). Programmatic callers pass false.
370
+ const rollbackOnFailure = opts.rollbackOnFailure !== false;
371
+ // Pre-upgrade snapshot { prevPin, prevVersion } — captured the instant the pin is moved, so a
372
+ // failure AFTER that point (install / migrate / health) can revert. null until then = nothing to undo.
373
+ let snapshot = null;
294
374
  if (!targetVersion) return { ok: false, error: 'missing --to <version> (the target core version)' };
295
375
 
296
376
  const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || readPinnedCoreVersion(instanceDir, fsImpl);
377
+ const doRollback = (reason) => rollback({ instanceDir, snapshot, fromVersion, toVersion: targetVersion, opts }, reason, deps);
297
378
  log(`bongos upgrade — ${CORE_PKG}: ${fromVersion || '(none)'} → ${targetVersion}`);
298
379
  log(` instance: ${instanceDir}${dryRun ? ' [DRY RUN — no changes]' : ''}`);
299
380
  if (fromVersion === targetVersion && !opts.force) {
@@ -317,7 +398,7 @@ async function runUpgrade(opts, deps = {}) {
317
398
  if (opts.from) return { ok: false, error: '--registry and --from are mutually exclusive (registry pull vs vendored tarball) — pick one core source' };
318
399
  if (!hasNpmToken()) log(' ! no NPM_TOKEN in env — `npm install` will 404 on the PRIVATE @bongos/core until a read-only automation token is provided (ADR 0134).');
319
400
  const prevPin = dryRun ? null : writeRegistryPin(instanceDir, targetVersion, fsImpl);
320
- if (!dryRun) writeNpmrc(instanceDir, { fsImpl, overwrite: false, log });
401
+ if (!dryRun) { writeNpmrc(instanceDir, { fsImpl, overwrite: false, log }); snapshot = { prevPin, prevVersion: fromVersion }; }
321
402
  log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${dryRun ? ' + write an env-fed .npmrc' : ''}${prevPin ? ` (was ${prevPin})` : ''}`);
322
403
  } else {
323
404
  let vendorRel = opts.pinPath || (opts.from ? null : path.join('vendor', `${ARTIFACT}-${targetVersion}.tgz`));
@@ -330,6 +411,7 @@ async function runUpgrade(opts, deps = {}) {
330
411
  return { ok: false, error: `no core tarball at ${vendorRel} — pass --from <path-to-.tgz> to vendor it (or --registry to pull from the npm registry)` };
331
412
  }
332
413
  const prevPin = dryRun ? null : writePin(instanceDir, vendorRel, fsImpl);
414
+ if (!dryRun) snapshot = { prevPin, prevVersion: fromVersion };
333
415
  log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → file:${vendorRel}${prevPin ? ` (was ${prevPin})` : ''}`);
334
416
  }
335
417
 
@@ -337,13 +419,27 @@ async function runUpgrade(opts, deps = {}) {
337
419
 
338
420
  // 3. npm install.
339
421
  const inst = npmInstall(instanceDir, run);
340
- if (!inst.ok) return { ok: false, error: `npm install failed (${inst.reason || 'exit ' + inst.code})`, fromVersion };
422
+ if (!inst.ok) {
423
+ const error = `npm install failed (${inst.reason || 'exit ' + inst.code})`;
424
+ if (rollbackOnFailure && snapshot) {
425
+ const rb = await doRollback('npm install failed');
426
+ return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb };
427
+ }
428
+ return { ok: false, error, fromVersion };
429
+ }
341
430
  log(' ✓ npm install');
342
431
 
343
432
  // 4. migrate (additive core_* migrations).
344
433
  if (!opts.skipMigrate) {
345
434
  const mig = runMigrate(instanceDir, run);
346
- if (!mig.ok) return { ok: false, error: `migrate failed (${mig.reason || 'exit ' + mig.code})`, fromVersion };
435
+ if (!mig.ok) {
436
+ const error = `migrate failed (${mig.reason || 'exit ' + mig.code})`;
437
+ if (rollbackOnFailure && snapshot) {
438
+ const rb = await doRollback('migrate failed');
439
+ return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb };
440
+ }
441
+ return { ok: false, error, fromVersion };
442
+ }
347
443
  log(' ✓ migrate');
348
444
  } else log(' • migrate skipped (--skip-migrate)');
349
445
 
@@ -383,6 +479,14 @@ async function runUpgrade(opts, deps = {}) {
383
479
  else err(` ! health ${opts.healthUrl} not ready after ${h.attempts} tr${h.attempts === 1 ? 'y' : 'ies'}: ${h.error || 'status ' + h.status}`);
384
480
  }
385
481
 
482
+ // A failed post-restart health check means the new core is serving broken — auto-revert (task
483
+ // 2149) instead of recording an "upgraded" row + leaving the instance down. The rollback writes
484
+ // its OWN rolled_back ledger row, so skip the normal insert below.
485
+ if (healthOk === false && rollbackOnFailure && snapshot) {
486
+ const rb = await doRollback('health check failed after restart');
487
+ return { ok: false, error: `health check failed after restart — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk: false, rolledBack: true, rollback: rb };
488
+ }
489
+
386
490
  const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note }, deps);
387
491
  if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
388
492
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
@@ -410,6 +514,7 @@ function parseArgs(argv) {
410
514
  skipMigrate: hasFlag('--skip-migrate', argv),
411
515
  skipMaterialize: hasFlag('--skip-materialize', argv),
412
516
  skipRegenDocs: hasFlag('--skip-regen-docs', argv),
517
+ rollbackOnFailure: !hasFlag('--no-rollback-on-failure', argv), // ADR 0100: auto-revert ON by default (task 2149)
413
518
  };
414
519
  }
415
520
 
@@ -432,10 +537,12 @@ async function main(argv = process.argv.slice(2)) {
432
537
  ' --skip-migrate do not run `npm run migrate`',
433
538
  ' --skip-materialize do not refresh .claude/',
434
539
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
540
+ ' --no-rollback-on-failure do NOT auto-revert on a failed install/migrate/health check (default: auto-rollback ON)',
435
541
  ' --dry-run show the plan; change nothing',
436
542
  ' --force skip the clean-tree + same-version guards',
437
543
  '',
438
544
  'Full bump: pre-flight → bump pin → npm install → migrate → materialize .claude → regen API docs+client → restart → verify.',
545
+ 'On a failure after the pin moves (install/migrate/health), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
439
546
  ].join('\n') + '\n');
440
547
  return 0;
441
548
  }
@@ -450,8 +557,8 @@ async function main(argv = process.argv.slice(2)) {
450
557
  }
451
558
 
452
559
  module.exports = {
453
- runUpgrade, parseArgs, main,
454
- versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin,
560
+ runUpgrade, parseArgs, main, rollback,
561
+ versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
455
562
  vendorTarball, gitTreeClean, preflightModules, npmInstall, runMigrate, regenerateApiArtifacts, restartService, recordLedger, pollHealth,
456
563
  CORE_PKG, ARTIFACT,
457
564
  };