@bongos/core 1.18.19 → 1.19.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 (259) hide show
  1. package/.bongos-core.json +547 -197
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
@@ -108,6 +108,12 @@ function isPublicRoutableIPv6(s) {
108
108
  if (!g) return false;
109
109
  if (g.slice(0, 7).every((x) => x === 0)) return false; // :: (unspecified) and ::1 (loopback)
110
110
  const h = g[0];
111
+ // ::/8 is IETF-RESERVED (RFC 4291 §2.4) — no host is reachable there, so an address
112
+ // in it is never a real leak. Excluding it is what stops degenerate shorthand like
113
+ // `5::` / `c9::` from matching: those are 2-3 chars that random BINARY bytes hit
114
+ // constantly, which flagged 7 phantom "public-ip" leaks across committed .png/.webp
115
+ // assets and left the C9 full-tree mirror gate permanently red (task 1002470).
116
+ if (h < 0x0100) return false; // reserved ::/8
111
117
  if (h >= 0xfe80 && h <= 0xfebf) return false; // link-local fe80::/10
112
118
  if (h >= 0xfc00 && h <= 0xfdff) return false; // ULA fc00::/7
113
119
  if (h >= 0xff00) return false; // multicast ff00::/8
@@ -377,7 +377,7 @@ function report(summary, FLAGS, { checkOnly }) {
377
377
  if (checkOnly) { console.log('(--check: no stamping)'); return; }
378
378
  if (summary.noop) { console.log('All chores at or above floor — nothing to stamp.'); return; }
379
379
 
380
- console.log(`\nReview gate: ${summary.autoPromote ? "AUTO-PROMOTE (copies land status='ready', live now)" : "REVIEW (copies land status='backlog'; an Archon promotes to 'ready')"}${FLAGS.dryRun ? ' [DRY RUN — nothing created]' : ''}`);
380
+ console.log(`\nReview gate: ${summary.autoPromote ? "AUTO-PROMOTE (copies land status='ready', live now)" : "REVIEW (copies land status='backlog'; a Metic+ builder promotes to 'ready')"}${FLAGS.dryRun ? ' [DRY RUN — nothing created]' : ''}`);
381
381
  for (const g of summary.stamped) {
382
382
  const who = `${g.discipline}/${g.template_id}`;
383
383
  if (g.error) console.log(` ✗ ${who}: ${g.error}`);
@@ -386,7 +386,7 @@ function report(summary, FLAGS, { checkOnly }) {
386
386
  else console.log(` ✓ ${who}: task #${g.task_id} [${g.status}] "${g.title}" (ref ${g.source_ref})`);
387
387
  }
388
388
  if (!summary.autoPromote && !FLAGS.dryRun && summary.stamped.some((g) => g.task_id)) {
389
- console.log('\nNext: an Archon promotes the new backlog copies to ready:');
389
+ console.log('\nNext: a Metic+ builder promotes the new backlog copies to ready:');
390
390
  console.log(' node scripts/gds/api.js POST /api/gds/tasks/<id>/promote (or the builders\' hall)');
391
391
  }
392
392
  }
@@ -399,10 +399,55 @@ function installedCoreTarball({ instanceRoot, fsImpl = fs } = {}) {
399
399
  return null;
400
400
  }
401
401
 
402
- // Clone the (empty) target_ref → standaloneRoot → materialize config/ + .claude/ + a
403
- // file:-pinned @bongos/core + lockfile (via `bongos init --vendor-core`, task 2053)
404
- // → commit + push. Idempotent (skips once the repo carries a package.json) and dry-run
405
- // safe (every mutation goes through the injected exec/writeFile). ADR 0108 / 0125.
402
+ // The onboard mode a provisioning row carries (migration provisioning_004, task 1002562).
403
+ // 'adopt' means target_ref is a repo the owner ALREADY has, so the scaffold must LAYER
404
+ // onto its history. Anything else — including a row written before the column existed, or
405
+ // an unrecognized value — resolves to 'greenfield', deliberately the SAFE direction:
406
+ // greenfield pushes a fresh tree and (carrying no --force) is REJECTED by a repo with
407
+ // history, whereas wrongly choosing 'adopt' would start rewriting a repo nobody declared.
408
+ function onboardMode(inst) {
409
+ return String((inst && inst.onboard_mode) || '') === 'adopt' ? 'adopt' : 'greenfield';
410
+ }
411
+
412
+ // Has the scaffold already completed in `root`? The marker is MODE-DEPENDENT, and reusing
413
+ // the greenfield one for adopt is a real bug: greenfield's marker is "package.json exists"
414
+ // (a fresh empty repo has none until we scaffold it), but an adopted repo is an existing
415
+ // project that almost certainly HAS a package.json already — so the greenfield check would
416
+ // report "already scaffolded" on the very first run and the instance would never become
417
+ // bootable. For adopt the honest marker is the thing the layering itself contributes: a
418
+ // @bongos/core dependency (both scope aliases the loader accepts, matching
419
+ // installedCoreTarball). fs injectable for tests; read-only, so dry-run safe.
420
+ function alreadyScaffolded(root, mode, { fsImpl = fs } = {}) {
421
+ const pkgPath = path.join(root, 'package.json');
422
+ if (!fsImpl.existsSync(pkgPath)) return false;
423
+ if (mode !== 'adopt') return true;
424
+ let pkg;
425
+ try { pkg = JSON.parse(fsImpl.readFileSync(pkgPath, 'utf8')); }
426
+ catch { return false; } // unparseable → let the adopt layer report it, don't skip
427
+ const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
428
+ return !!(deps['@bongos/core'] || deps['@cloudbongos/core']);
429
+ }
430
+
431
+ // Materialize + push the instance's own repo. TWO paths, chosen by onboard_mode:
432
+ //
433
+ // greenfield — the target_ref repo is EMPTY (github-repos.createUserRepo makes it with
434
+ // auto_init:false), so there is nothing to clone: `git init` locally,
435
+ // materialize, and push the first commit at main.
436
+ // adopt — the target_ref repo is the owner's OWN existing project. CLONE it (so its
437
+ // history and files come along), LAYER the instance config additively via
438
+ // `bongos init --adopt --accept` (which never overwrites a pre-existing
439
+ // file), and push the layer commit as an ordinary fast-forward on top.
440
+ //
441
+ // Both end at a repo carrying config/ + .claude/ + a file:-pinned @bongos/core + lockfile,
442
+ // which is what makes it `npm ci --omit=dev`-installable by the rest of provisioning.
443
+ //
444
+ // NEITHER PATH EVER USES --force. That is a safety property, not an oversight: since ADR
445
+ // 0155 the credential here can write to every repo the owner owns, so a force-push would
446
+ // be a route to destroying a user's work. A rejected push is the correct, recoverable
447
+ // outcome. Asserted by tests.
448
+ //
449
+ // Idempotent (see alreadyScaffolded) and dry-run safe (every mutation goes through the
450
+ // injected exec/writeFile). ADR 0108 / 0121 / 0125.
406
451
  async function scaffoldStandaloneRepo(inst, deps) {
407
452
  // The scaffold BUILDS + pushes the instance's repo (git clone, package-core,
408
453
  // `bongos init`, git push) — control-plane work that needs the packaging tools +
@@ -414,12 +459,12 @@ async function scaffoldStandaloneRepo(inst, deps) {
414
459
  const writeFile = deps.controlWriteFile || deps.writeFile;
415
460
  const root = standaloneRoot(inst);
416
461
  const repo = parseTargetRef(inst.target_ref);
462
+ const mode = onboardMode(inst);
417
463
  if (!repo) {
418
464
  throw new Error(`standalone instance ${inst.slug}: target_ref "${inst.target_ref || '(unset)'}" is not an owner/repo — set it (e.g. <owner>/${inst.slug}) so the scaffold knows which repo to clone + push.`);
419
465
  }
420
- // Idempotent: a completed scaffold leaves package.json (the file:-pinned core). Skip.
421
- if (fs.existsSync(path.join(root, 'package.json'))) {
422
- log(` [scaffold] ${root} already scaffolded (package.json present) — skipping`);
466
+ if (alreadyScaffolded(root, mode)) {
467
+ log(` [scaffold] ${root} already scaffolded (mode=${mode}) — skipping`);
423
468
  return { already: true };
424
469
  }
425
470
  const coreV = coreVersionSafe();
@@ -427,7 +472,9 @@ async function scaffoldStandaloneRepo(inst, deps) {
427
472
  throw new Error('cannot resolve the core version (src/module-api CORE_VERSION) — run the scaffold from the control-plane checkout.');
428
473
  }
429
474
  const remote = instanceRepoRemote(repo);
430
- log(` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
475
+ log(mode === 'adopt'
476
+ ? ` [scaffold] ${repo.owner}/${repo.name} → ${root} (ADOPT: clone → layer additively → pin core ${coreV} → commit → fast-forward push)`
477
+ : ` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
431
478
 
432
479
  // Owner login = the founding firstAdmin. Best-effort; the spec falls back to the repo owner.
433
480
  let ownerLogin = null;
@@ -438,79 +485,21 @@ async function scaffoldStandaloneRepo(inst, deps) {
438
485
  } catch { /* fall back to the repo owner */ }
439
486
  }
440
487
 
441
- // 1. Materialize the working tree + set the initial branch to `main`. A freshly-created
442
- // instance repo is EMPTY (github-repos.createUserRepo, auto_init:false), so there is
443
- // NOTHING to clone — and a git DEPLOY KEY is per-repo, so the shared PROVISION_GIT_SSH_ALIAS
444
- // key (e.g. `github-instances`, a deploy key scoped to ONE repo) can't even READ a
445
- // consumer's brand-new repo under their own account: `git clone` 500s with "Repository not
446
- // found." This is the clone-side twin of the task-1002317 push bug. So `git init` locally
447
- // instead of cloning, and set `origin` to the SSH remote for the no-token fallback push
448
- // below; the OAuth-preferred push (its token CREATED the repo, so it can write) handles the
449
- // upload for consumer repos. A re-run after a partial init reuses the existing dir.
450
- if (!fs.existsSync(path.join(root, '.git'))) {
451
- exec(`mkdir -p ${root}`);
452
- exec(`git -C ${root} init -q`);
453
- exec(`git -C ${root} remote add origin ${remote}`);
454
- }
455
- exec(`git -C ${root} checkout -B main`);
456
-
457
- // Core-install path (ADR 0134, task 2090). DEFAULT = vendor a tarball built from THIS
458
- // control-plane checkout (offline, proven — task 2053). OPT-IN = pull the PRIVATE
459
- // @bongos/core from the npm registry (PROVISION_CORE_FROM_REGISTRY=1). The registry
460
- // path needs NPM_TOKEN at `npm ci` time; if the flag is set but no token is present we
461
- // FAIL SOFT to the vendored path so a tokenless standup still works (never a broken
462
- // instance). The env is injectable for tests; the runner falls back to process.env.
463
- const env = deps.env || process.env;
464
- const wantRegistry = registryCoreRequested(env);
465
- const useRegistry = wantRegistry && hasNpmToken(env);
466
- if (wantRegistry && !useRegistry) {
467
- log(' [scaffold] PROVISION_CORE_FROM_REGISTRY set but no NPM_TOKEN in env — falling back to the vendored-tarball core (ADR 0134 fail-soft).');
468
- }
469
-
470
- // materialize config/ + .claude/ + a pinned package.json + lockfile via `bongos init`.
471
- // --no-seed: the instance's own DB is seeded by migrate, not here. Two mutually
472
- // exclusive core-resolution shapes:
473
- // registry (--registry-core): caret pin + env-fed .npmrc; `npm ci` pulls from the
474
- // registry. init generates the lockfile (NPM_TOKEN is present — we gated on it).
475
- // vendored (--vendor-core, default): a tarball built from this checkout, file:-pinned.
476
- const spec = instanceInitSpec(inst, { ownerLogin });
477
- const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
478
- writeFile(specPath, JSON.stringify(spec, null, 2));
479
- if (useRegistry) {
480
- log(` [scaffold] core install: registry — PRIVATE @bongos/core@${coreV} via env-fed NPM_TOKEN (ADR 0134)`);
481
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed --force --registry-core`);
482
- } else {
483
- // Vendored tarball. PREFER a pre-built pinned tarball this control plane already has
484
- // (a consumer instance's own `file:` core pin) — rebuilding via package-core.js aborts
485
- // with an EMPTY core set when this runner IS a consumer instance (REPO_ROOT is inside
486
- // gitignored node_modules), which left new repos empty (task 1002275). Only rebuild
487
- // from source when there is no such pin (a genuine core source checkout).
488
- let tgz = installedCoreTarball({ instanceRoot: deps.instanceRoot });
489
- if (tgz) {
490
- log(` [scaffold] core install: reusing the pinned core tarball this control plane runs (${path.basename(tgz)}) — no source rebuild (task 1002275)`);
491
- } else {
492
- log(` [scaffold] core install: building a fresh vendored tarball from source (package-core.js)`);
493
- exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/bongos-core-<v>.tgz
494
- tgz = path.join(REPO_ROOT, 'dist', `bongos-core-${coreV}.tgz`);
495
- }
496
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed --force --vendor-core ${tgz}`);
497
- }
498
-
499
- // 4. commit + push the scaffold to the instance's own main (keep node_modules out).
500
- writeFile(path.join(root, '.gitignore'), INSTANCE_GITIGNORE);
501
- exec(`git -C ${root} config user.name "Cloud Bongos"`);
502
- exec(`git -C ${root} config user.email "bot@cloudbongos.com"`);
503
- exec(`git -C ${root} add -A`);
504
- exec(`git -C ${root} commit -m "chore: scaffold ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0108)"`, { allowFail: true });
505
- // Push. A GitHub DEPLOY KEY is per-repo, so the box's shared key can't write to a NEW
506
- // project repo — the second empty-repo cause (task 1002317). PREFER the instance owner's
507
- // OAuth token (it CREATED the repo via github-repos.createUserRepo, so `public_repo`
508
- // already grants write). The token rides a chmod-600 git credential-store file — never
509
- // the logged command or makeExec's env (both are surfaced) — and is removed right after
510
- // (finally, so a failed push can't leave it on disk). No token (org-owned repo, or
511
- // secret-box off) → fall back to the SSH `origin` push (unchanged; e.g. an instance whose
512
- // matching deploy key was cloned into origin). `deps.getGithubToken` is injectable for
513
- // tests; production resolves it lazily against the core db slice with the runner's pool.
488
+ // Resolve the instance owner's OAuth token BEFORE any git network op.
489
+ //
490
+ // Greenfield only needed it at PUSH time, so the lookup used to live down beside the
491
+ // push. Adopt needs it EARLIER: its first act is to clone the owner's own repo, and a
492
+ // PRIVATE one is unreadable by the control plane's shared deploy key (a deploy key is
493
+ // per-repo). Since ADR 0155 this token carries the `repo` scope, so it can both read and
494
+ // write the owner's private repos — which is what makes adopt possible at all.
495
+ //
496
+ // A GitHub DEPLOY KEY is per-repo, so the box's shared key also can't WRITE to a new
497
+ // project repo — the second empty-repo cause (task 1002317) — hence preferring this
498
+ // token on both paths. It rides a chmod-600 git credential-store file, never the logged
499
+ // command or makeExec's env (makeExec surfaces both), and is removed in a `finally` so a
500
+ // failed git op can't leave it on disk. No token (org-owned repo, or secret-box off) →
501
+ // fall back to the SSH `origin` remote. `deps.getGithubToken` is injectable for tests;
502
+ // production resolves it lazily against the core db slice with the runner's pool.
514
503
  let ownerToken = null;
515
504
  if (inst.owner_builder_id) {
516
505
  try {
@@ -520,21 +509,166 @@ async function scaffoldStandaloneRepo(inst, deps) {
520
509
  ownerToken = r && r.token ? r.token : null;
521
510
  } catch (e) { log(` [scaffold] owner OAuth token lookup failed (${e && e.message}) — using the SSH remote`); }
522
511
  }
523
- if (ownerToken) {
524
- const credFile = path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`);
525
- writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
526
- const httpsUrl = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
527
- try {
528
- exec(`git -C ${root} -c credential.helper='store --file=${credFile}' push ${httpsUrl} HEAD:main`);
529
- log(` [scaffold] pushed to ${repo.owner}/${repo.name}@main via the owner's GitHub token (task 1002317)`);
530
- } finally {
531
- exec(`rm -f ${credFile}`, { allowFail: true });
512
+ // The credential-store file + the credential-LESS URL git resolves against it. The URL
513
+ // carries only the `x-access-token` username (no secret), so it is safe in a logged argv.
514
+ const credFile = ownerToken ? path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`) : null;
515
+ const httpsUrl = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
516
+ const credOpt = credFile ? `-c credential.helper='store --file=${credFile}' ` : '';
517
+ if (credFile) writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
518
+
519
+ try {
520
+ // 1. Materialize the working tree.
521
+ //
522
+ // ADOPT — CLONE the owner's existing repo, so its history + files come along and the
523
+ // layer commit lands as an ordinary descendant of its current tip. This is the whole
524
+ // point: `git init`ing a fresh tree here produces an UNRELATED history, which the
525
+ // remote rejects as a non-fast-forward (and, were it ever forced, would erase the
526
+ // owner's work). Cloning over the token's HTTPS URL also covers the private case the
527
+ // shared deploy key cannot read.
528
+ //
529
+ // GREENFIELD — a freshly-created instance repo is EMPTY (github-repos.createUserRepo,
530
+ // auto_init:false), so there is NOTHING to clone; and the shared PROVISION_GIT_SSH_ALIAS
531
+ // deploy key can't even READ a consumer's brand-new repo under their own account
532
+ // (`git clone` fails "Repository not found" — the clone-side twin of the task-1002317
533
+ // push bug). So `git init` locally and set `origin` to the SSH remote for the no-token
534
+ // fallback push. Unchanged behavior.
535
+ //
536
+ // Either way a re-run after a partial attempt reuses the existing dir.
537
+ const hasGit = fs.existsSync(path.join(root, '.git'));
538
+ if (mode === 'adopt') {
539
+ if (!hasGit) {
540
+ exec(`mkdir -p ${root}`);
541
+ exec(`git ${credOpt}clone ${credFile ? httpsUrl : remote} ${root}`);
542
+ // Normalize `origin` to the SSH remote, matching what the greenfield path leaves
543
+ // behind. Two reasons: the clone URL would otherwise persist as origin, and the
544
+ // ongoing deploy (standalonePullCmd → `git pull --ff-only origin main`, and the box
545
+ // timer's `git reset --hard origin/main`) reads origin — it must be a form that can
546
+ // authenticate durably, which the one-shot token URL cannot (its credential file is
547
+ // deleted below). Nothing durable ever holds the token either way; the pushes here
548
+ // always pass an explicit URL.
549
+ if (credFile) exec(`git -C ${root} remote set-url origin ${remote}`);
550
+ } else {
551
+ exec(`git -C ${root} ${credOpt}fetch ${credFile ? httpsUrl : 'origin'} main`);
552
+ }
553
+ // The rest of provisioning is main-only (standalonePullCmd is `git pull --ff-only
554
+ // origin main`, and the box's deploy timer does `git reset --hard origin/main`), so a
555
+ // repo whose default branch is not `main` would be layered and pushed somewhere the
556
+ // deploy never reads. Fail loudly with the fix rather than silently half-working.
557
+ const head = exec(`git -C ${root} rev-parse --abbrev-ref HEAD`, { allowFail: true });
558
+ const branch = String((head && head.stdout) || '').trim();
559
+ if (branch && branch !== 'main' && branch !== 'HEAD') {
560
+ throw new Error(`standalone instance ${inst.slug}: adopting ${repo.owner}/${repo.name} but its default branch is "${branch}", not "main". The instance deploy only ever reads origin/main, so the layer would never be served. Rename the default branch to main (or point target_ref at a repo whose default is main) and re-run.`);
561
+ }
562
+ exec(`git -C ${root} checkout main`, { allowFail: true });
563
+ } else {
564
+ if (!hasGit) {
565
+ exec(`mkdir -p ${root}`);
566
+ exec(`git -C ${root} init -q`);
567
+ exec(`git -C ${root} remote add origin ${remote}`);
568
+ }
569
+ exec(`git -C ${root} checkout -B main`);
532
570
  }
533
- } else {
534
- exec(`git -C ${root} push -u origin main`);
535
- log(` [scaffold] pushed initial scaffold to ${repo.owner}/${repo.name}@main (SSH origin)`);
571
+
572
+ // Core-install path (ADR 0134, task 2090). DEFAULT = vendor a tarball built from THIS
573
+ // control-plane checkout (offline, proven — task 2053). OPT-IN = pull the PRIVATE
574
+ // @bongos/core from the npm registry (PROVISION_CORE_FROM_REGISTRY=1). The registry
575
+ // path needs NPM_TOKEN at `npm ci` time; if the flag is set but no token is present we
576
+ // FAIL SOFT to the vendored path so a tokenless standup still works (never a broken
577
+ // instance). The env is injectable for tests; the runner falls back to process.env.
578
+ const env = deps.env || process.env;
579
+ const wantRegistry = registryCoreRequested(env);
580
+ const useRegistry = wantRegistry && hasNpmToken(env);
581
+ if (wantRegistry && !useRegistry) {
582
+ log(' [scaffold] PROVISION_CORE_FROM_REGISTRY set but no NPM_TOKEN in env — falling back to the vendored-tarball core (ADR 0134 fail-soft).');
583
+ }
584
+
585
+ // materialize config/ + .claude/ + a pinned package.json + lockfile via `bongos init`.
586
+ // --no-seed: the instance's own DB is seeded by migrate, not here (and on the adopt path
587
+ // the REAL-state seeding + backlog import happen when the owner runs `bongos init --adopt`
588
+ // themselves, against the live instance — the step the wizard walks them through).
589
+ //
590
+ // MODE FLAGS. Greenfield passes --force (overwrite is safe on an empty repo). Adopt passes
591
+ // --adopt --accept instead and NEVER --force: --adopt takes init's layering path, which
592
+ // writes each config only where absent and leaves pre-existing files untouched, and
593
+ // --accept clears the interactive pre-flight gate for this non-interactive runner (a
594
+ // BLOCKING pre-flight finding still hard-stops — it cannot be waved through). init refuses
595
+ // --adopt together with --force, so the two flag sets are mutually exclusive by design.
596
+ //
597
+ // Two mutually exclusive core-resolution shapes, orthogonal to the mode:
598
+ // registry (--registry-core): caret pin + env-fed .npmrc; `npm ci` pulls from the
599
+ // registry. init generates the lockfile (NPM_TOKEN is present — we gated on it).
600
+ // vendored (--vendor-core, default): a tarball built from this checkout, file:-pinned.
601
+ const modeFlags = mode === 'adopt' ? '--adopt --accept' : '--force';
602
+ const spec = instanceInitSpec(inst, { ownerLogin });
603
+ const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
604
+ writeFile(specPath, JSON.stringify(spec, null, 2));
605
+ if (useRegistry) {
606
+ log(` [scaffold] core install: registry — PRIVATE @bongos/core@${coreV} via env-fed NPM_TOKEN (ADR 0134)`);
607
+ exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --registry-core`);
608
+ } else {
609
+ // Vendored tarball. PREFER a pre-built pinned tarball this control plane already has
610
+ // (a consumer instance's own `file:` core pin) — rebuilding via package-core.js aborts
611
+ // with an EMPTY core set when this runner IS a consumer instance (REPO_ROOT is inside
612
+ // gitignored node_modules), which left new repos empty (task 1002275). Only rebuild
613
+ // from source when there is no such pin (a genuine core source checkout).
614
+ let tgz = installedCoreTarball({ instanceRoot: deps.instanceRoot });
615
+ if (tgz) {
616
+ log(` [scaffold] core install: reusing the pinned core tarball this control plane runs (${path.basename(tgz)}) — no source rebuild (task 1002275)`);
617
+ } else {
618
+ log(` [scaffold] core install: building a fresh vendored tarball from source (package-core.js)`);
619
+ exec(`node scripts/gds/package-core.js`); // cwd = REPO_ROOT → dist/bongos-core-<v>.tgz
620
+ tgz = path.join(REPO_ROOT, 'dist', `bongos-core-${coreV}.tgz`);
621
+ }
622
+ exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
623
+ }
624
+
625
+ // 4. commit + push to the instance's own main (keep node_modules out).
626
+ //
627
+ // .gitignore: greenfield OWNS the file. Adopt must not clobber the owner's existing
628
+ // ignores, so it only writes one where none exists — and where one does, it APPENDS the
629
+ // entries we actually require (node_modules/, the vendored-core exceptions, the generated
630
+ // nav docs) if they are missing, since `npm ci` + `git reset --hard` on the box depend on
631
+ // them. Duplicate lines in a .gitignore are harmless, so the append is idempotent-safe.
632
+ const giPath = path.join(root, '.gitignore');
633
+ if (mode === 'adopt' && fs.existsSync(giPath)) {
634
+ const existing = fs.readFileSync(giPath, 'utf8');
635
+ const missing = INSTANCE_GITIGNORE.split('\n')
636
+ .filter((l) => l.trim() && !l.startsWith('#'))
637
+ .filter((l) => !existing.split('\n').some((e) => e.trim() === l.trim()));
638
+ if (missing.length) {
639
+ writeFile(giPath, `${existing.replace(/\n*$/, '\n')}\n# Added by Cloud Bongos (adopt) — required by the instance deploy.\n${missing.join('\n')}\n`);
640
+ log(` [scaffold] appended ${missing.length} required .gitignore entr(ies) — existing rules left intact`);
641
+ } else {
642
+ log(' [scaffold] .gitignore already covers the required entries — left untouched');
643
+ }
644
+ } else {
645
+ writeFile(giPath, INSTANCE_GITIGNORE);
646
+ }
647
+ exec(`git -C ${root} config user.name "Cloud Bongos"`);
648
+ exec(`git -C ${root} config user.email "bot@cloudbongos.com"`);
649
+ exec(`git -C ${root} add -A`);
650
+ const commitMsg = mode === 'adopt'
651
+ ? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0121)`
652
+ : `chore: scaffold ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0108)`;
653
+ exec(`git -C ${root} commit -m "${commitMsg}"`, { allowFail: true });
654
+
655
+ // Push. NO --force on either path, ever (see the function header): the ADR 0155 `repo`
656
+ // credential can write to every repo the owner owns, so a force here would be a route to
657
+ // destroying their work. Adopt's commit is a descendant of the tip it cloned, so an
658
+ // ordinary push fast-forwards; if the remote moved underneath us the rejection is the
659
+ // correct outcome and a re-run picks up the new tip.
660
+ if (credFile) {
661
+ exec(`git -C ${root} ${credOpt}push ${httpsUrl} HEAD:main`);
662
+ log(` [scaffold] ${mode === 'adopt' ? 'pushed the adopt layer' : 'pushed'} to ${repo.owner}/${repo.name}@main via the owner's GitHub token (task 1002317)`);
663
+ } else {
664
+ exec(`git -C ${root} push -u origin main`);
665
+ log(` [scaffold] pushed ${mode === 'adopt' ? 'the adopt layer' : 'initial scaffold'} to ${repo.owner}/${repo.name}@main (SSH origin)`);
666
+ }
667
+ return { ok: true, scaffolded: true, mode };
668
+ } finally {
669
+ // Always remove the credential file — a failed clone/push must not leave it on disk.
670
+ if (credFile) exec(`rm -f ${credFile}`, { allowFail: true });
536
671
  }
537
- return { ok: true, scaffolded: true };
538
672
  }
539
673
 
540
674
  // The per-instance systemd unit (the infra/cloudbongos.service shape): one Node process
@@ -1767,6 +1901,8 @@ module.exports = {
1767
1901
  standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1768
1902
  // standalone scaffold (ADR 0108 / 0125, task 2055; consumer-core fix task 1002275)
1769
1903
  parseTargetRef, instanceRepoRemote, instanceInitSpec, installedCoreTarball, scaffoldStandaloneRepo, coreVersionSafe,
1904
+ // adopt vs greenfield onboarding (ADR 0121, task 1002562)
1905
+ onboardMode, alreadyScaffolded,
1770
1906
  zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
1771
1907
  caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
1772
1908
  // executors + orchestration (injectable deps for tests)
@@ -74,7 +74,7 @@ const INTEGRATION = new Set([
74
74
  // .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
75
75
  'consumer_layout_boot',
76
76
  'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
77
- 'linkify_refs', 'memory_isolation',
77
+ 'governance_fold_bench', 'governance_live_proof', 'linkify_refs', 'memory_isolation',
78
78
  'memory_pull', 'needs_rebase', 'parallel-load', 'reward_mode_cost_plus_only',
79
79
  'session_records', 'setup_smoke',
80
80
  'status-probe', 'subdomain_auth',
@@ -96,6 +96,14 @@ const KNOWN_FAILING = new Set([
96
96
  'autotile_variants',
97
97
  'autotile_xverify',
98
98
  'anti_grid',
99
+ // #1002617: red in every clean environment (Linux CI and a Windows checkout
100
+ // alike), surfaced by the unit lane's first-ever runs. Parked so the `unit`
101
+ // check can be required (a gate that's already red gates nothing); per-test
102
+ // diagnoses live in that task. Remove each here as it is fixed.
103
+ // PAIRING: tests/known_failing_ratchet.mjs keeps EXECUTING each parked test
104
+ // and fails the suite the moment one starts passing — so this set cannot rot.
105
+ 'platform_boot',
106
+ 'served_paths_shipped',
99
107
  ]);
100
108
 
101
109
  // Discover modules/<key>/tests/*.mjs. Pure-ish (reads disk; `isEnabled` is
@@ -199,6 +199,28 @@ function restartPreview(deps = {}) {
199
199
  run(cmd, argv, { stdio: 'inherit' });
200
200
  }
201
201
 
202
+ // task 1002607: the discriminator probe URL — same origin as the health URL, at
203
+ // the API healthz path only the FULL server mounts. Pure + exported for tests.
204
+ function apiHealthzProbeUrl(healthUrl) {
205
+ try { return new URL('/api/gds/healthz', healthUrl).toString(); } catch { return null; }
206
+ }
207
+
208
+ // task 1002607: is the thing answering the health URL actually the MAIN server?
209
+ // The game-only preview entry (ADR 0052) mounts no /api/gds, so a 200 from the
210
+ // API healthz proves the answerer is the full server — whose default bind is the
211
+ // SAME 127.0.0.1:3000 the box health check probes — not the preview. A probe
212
+ // error/404 means "not the main server", which is the expected preview answer.
213
+ async function answeredByMainServer(healthUrl, fetchImpl) {
214
+ const probe = apiHealthzProbeUrl(healthUrl);
215
+ if (!probe) return false;
216
+ try {
217
+ const res = await fetchImpl(probe, { signal: AbortSignal.timeout(5_000) });
218
+ return res.status === 200;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
202
224
  async function waitHealthy(deps = {}) {
203
225
  const fetchImpl = deps.fetchImpl || fetch;
204
226
  const healthUrl = deps.healthUrl || HEALTH_URL;
@@ -207,7 +229,19 @@ async function waitHealthy(deps = {}) {
207
229
  while (Date.now() < deadline) {
208
230
  try {
209
231
  const res = await fetchImpl(healthUrl, { signal: AbortSignal.timeout(5_000) });
210
- if (res.status < 500) return { ok: true };
232
+ // task 1002607: was `< 500`, which called a 404 "healthy" — any wrong server
233
+ // with nothing at '/' passed. And a good status alone doesn't prove the
234
+ // PREVIEW answered: the main server binds the same port, so a dead preview
235
+ // "passed" whenever the full server was up. Cross-examine the answerer.
236
+ if (res.status < 400) {
237
+ if (await answeredByMainServer(healthUrl, fetchImpl)) {
238
+ return {
239
+ ok: false,
240
+ reason: `${healthUrl} is answered by the MAIN server (/api/gds/healthz responded 200), not the game preview — the preview did not start (its port is taken by the full server)`,
241
+ };
242
+ }
243
+ return { ok: true };
244
+ }
211
245
  lastErr = `HTTP ${res.status}`;
212
246
  } catch (e) {
213
247
  lastErr = e.message;
@@ -536,9 +570,7 @@ async function runShipGate({ taskId, files, skipStage, tty, approved }, deps = {
536
570
  if (!staged.ok) {
537
571
  // #1068: a HARD failure (couldn't sync the worktree to the served checkout,
538
572
  // or the preview verifiably served stale code) means a review here would be
539
- // a LIE — abort with the claim intact so the builder fixes it. A SOFT
540
- // failure (the preview simply didn't come up) must not brick shipping — the
541
- // gate is a review aid, not a wall: warn loudly and continue.
573
+ // a LIE — abort with the claim intact so the builder fixes it.
542
574
  if (staged.hard) {
543
575
  return {
544
576
  ok: false,
@@ -547,8 +579,16 @@ async function runShipGate({ taskId, files, skipStage, tty, approved }, deps = {
547
579
  reason: `sandbox staging could not be trusted (${staged.reason}) — fix it, then re-run /builder-ship (or --skip-stage to bypass the review). Your claim is still active`,
548
580
  };
549
581
  }
550
- console.error(`warn: sandbox staging failed (${staged.reason}); continuing without the review step.`);
551
- return { ok: true };
582
+ // task 1002607 (§4.4 station 9): a SOFT failure (the preview didn't come up)
583
+ // used to WARN one stderr line and return ok — the review gate silently
584
+ // turned itself off and the ship went on as if the review had happened.
585
+ // Fail closed like the smoke gate (task 1002605): abort with the claim
586
+ // intact; --skip-stage is the explicit, visible bypass. No review:true —
587
+ // this is a failure to present, not a calm review pause.
588
+ return {
589
+ ok: false,
590
+ reason: `sandbox staging failed (${staged.reason}) — the sandbox review step DID NOT run. Fix the preview and re-run /builder-ship, or run /builder-ship --skip-stage to explicitly ship WITHOUT the sandbox review. Your claim is still active`,
591
+ };
552
592
  }
553
593
 
554
594
  if (d.action === 'stage_abort') {
@@ -578,10 +618,20 @@ async function runShipGate({ taskId, files, skipStage, tty, approved }, deps = {
578
618
  return { ok: true, sandboxReview: approvalRecord('tty', staged.url, deps) };
579
619
  }
580
620
 
621
+ // task 1002607: the old message ("this clone has no game preview entry") implied
622
+ // the BUILDER's clone was at fault, when the commonest cause is that the checkout
623
+ // simply carries no game — src/preview-server.js is an instance's product file
624
+ // (the ADR 0052 game-only server); the core repo does not ship one. Name what is
625
+ // actually missing and both real causes. Exported so the wording is pinned.
626
+ const NO_SANDBOX_MSG =
627
+ 'No sandbox here: this machine is not a dev box, and this checkout has no game preview entry (src/preview-server.js — the ADR 0052 game-only server).';
628
+ const NO_SANDBOX_DETAIL =
629
+ 'That entry file comes from an instance\'s product layer. This repo does not ship one, and a partial/sparse clone of an instance that has one also lacks it — nothing here can be staged or previewed.';
630
+
581
631
  async function cliMain() {
582
632
  const cmd = process.argv[2] || 'stage';
583
633
  const ctx = resolvePreviewContext();
584
- const NO_SANDBOX = 'No sandbox here — not a dev box, and this clone has no game preview entry (src/preview-server.js).';
634
+ const NO_SANDBOX = NO_SANDBOX_MSG;
585
635
 
586
636
  if (cmd === 'url') {
587
637
  console.log(ctx && ctx.url ? ctx.url : '(no sandbox here — not a dev box, and no local game preview in this clone)');
@@ -606,9 +656,12 @@ async function cliMain() {
606
656
  process.exit(2);
607
657
  }
608
658
  if (!ctx) {
659
+ // task 1002607: an explicit `stage` invocation that can stage nothing is a
660
+ // FAILED command, not a quiet success — exit 1 was exit 0, which read as
661
+ // "staged fine" to any caller that only checks the code.
609
662
  console.log(NO_SANDBOX);
610
- console.log('A full local clone gets a private localhost preview; a partial clone does not.');
611
- return;
663
+ console.log(NO_SANDBOX_DETAIL);
664
+ process.exit(1);
612
665
  }
613
666
  const res = await stage({});
614
667
  if (!res.ok) {
@@ -643,6 +696,10 @@ module.exports = {
643
696
  syncWorktreeToServed,
644
697
  pickVerifyTarget,
645
698
  verifyServedFresh,
699
+ // task 1002607: the health truth table + the honest no-sandbox wording, pinned.
700
+ waitHealthy,
701
+ apiHealthzProbeUrl,
702
+ NO_SANDBOX_MSG,
646
703
  };
647
704
 
648
705
  if (require.main === module) {
@@ -48,6 +48,7 @@ const SENSITIVE_DOC_GLOBS = [
48
48
  'docs/canonical-permissions.md', // the rank-ladder / authority reference
49
49
  'docs/security/', // threat models + red-team reports
50
50
  'docs/audits/', // security + architecture audit findings
51
+ 'docs/governance/', // the gate ENUMERATION + choke-point map — task 1002598
51
52
  'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
52
53
  'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
53
54
  ];