@bongos/core 1.19.1080 → 1.20.1

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 (258) hide show
  1. package/.bongos-core.json +573 -188
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +34 -0
  5. package/clients/bongos-client/index.cjs +34 -0
  6. package/clients/bongos-client/index.d.ts +53 -4
  7. package/clients/bongos-client/index.mjs +34 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
  14. package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
  15. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  16. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  17. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  18. package/docs/adr/README.md +4 -0
  19. package/docs/api/openapi.json +995 -40
  20. package/docs/api-reference.md +33 -8
  21. package/docs/architecture.md +6 -2
  22. package/docs/copy-inventory.md +620 -553
  23. package/docs/copy-registry.json +1467 -820
  24. package/docs/file-map.md +2 -0
  25. package/docs/module-api-changelog.md +8 -0
  26. package/docs/modules-contract.md +1 -0
  27. package/docs/page-inventory.json +38 -4
  28. package/docs/page-readings.json +1674 -1566
  29. package/migrations/core_260_goals_working_area.sql +63 -0
  30. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  31. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  32. package/modules/agents/lib/validate.js +43 -0
  33. package/modules/autonomy/gauge.js +38 -2
  34. package/modules/grading/grader-subagent.js +37 -2
  35. package/modules/grading/grader-workers/reader.js +65 -5
  36. package/modules/hall-ui/public/approval-queue.css +33 -7
  37. package/modules/hall-ui/public/approval-queue.js +9 -2
  38. package/modules/hall-ui/public/atlas.html +1 -1
  39. package/modules/hall-ui/public/blockers.html +1 -1
  40. package/modules/hall-ui/public/board-room.html +1 -1
  41. package/modules/hall-ui/public/brand-holes.js +121 -0
  42. package/modules/hall-ui/public/collab.html +1 -1
  43. package/modules/hall-ui/public/collab.js +1 -1
  44. package/modules/hall-ui/public/copy-desk.html +1 -1
  45. package/modules/hall-ui/public/deploy.html +6 -1
  46. package/modules/hall-ui/public/deploy.js +22 -8
  47. package/modules/hall-ui/public/diagrams.html +1 -1
  48. package/modules/hall-ui/public/dom-utils.js +20 -0
  49. package/modules/hall-ui/public/drachmae.html +1 -1
  50. package/modules/hall-ui/public/fleet.html +1 -1
  51. package/modules/hall-ui/public/gate.html +1 -1
  52. package/modules/hall-ui/public/goals.html +1 -1
  53. package/modules/hall-ui/public/government.html +1 -1
  54. package/modules/hall-ui/public/idea.html +1 -1
  55. package/modules/hall-ui/public/idea.js +28 -0
  56. package/modules/hall-ui/public/ideas.html +1 -1
  57. package/modules/hall-ui/public/ideas.js +19 -3
  58. package/modules/hall-ui/public/index.html +5 -1
  59. package/modules/hall-ui/public/modules.html +1 -1
  60. package/modules/hall-ui/public/primer.html +1 -1
  61. package/modules/hall-ui/public/profile-nudge.js +21 -4
  62. package/modules/hall-ui/public/profile.html +1 -1
  63. package/modules/hall-ui/public/profile.js +1 -1
  64. package/modules/hall-ui/public/project-settings.html +1 -1
  65. package/modules/hall-ui/public/ranks.html +1 -1
  66. package/modules/hall-ui/public/roadmap.html +1 -1
  67. package/modules/hall-ui/public/roster.html +1 -1
  68. package/modules/hall-ui/public/sessions.html +1 -1
  69. package/modules/hall-ui/public/settings.html +19 -30
  70. package/modules/hall-ui/public/settings.js +24 -177
  71. package/modules/hall-ui/public/settings.states.json +1 -1
  72. package/modules/hall-ui/public/shell.js +4 -2
  73. package/modules/hall-ui/public/studio.css +14 -8
  74. package/modules/hall-ui/public/studio.html +5 -4
  75. package/modules/hall-ui/public/task.html +1 -1
  76. package/modules/hall-ui/public/thinking.css +6 -3
  77. package/modules/hall-ui/public/thinking.html +1 -1
  78. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  79. package/modules/hall-ui/public/tweak-editor.css +142 -11
  80. package/modules/hall-ui/public/tweak-editor.html +25 -9
  81. package/modules/hall-ui/public/tweak-editor.js +243 -10
  82. package/modules/hall-ui/public/watch.html +1 -1
  83. package/modules/hall-ui/public/work.html +1 -1
  84. package/modules/hall-ui/records/approval-queue.md +6 -0
  85. package/modules/hall-ui/records/tweak-editor.md +6 -0
  86. package/modules/ideas/ratify.js +36 -8
  87. package/modules/ideas/routes/ratify-goal.js +6 -0
  88. package/modules/lifecycle/db-analytics.js +79 -10
  89. package/modules/lifecycle/db-claims.js +48 -1
  90. package/modules/lifecycle/db-deps-criteria.js +94 -40
  91. package/modules/lifecycle/db-goals.js +4 -1
  92. package/modules/lifecycle/db-grade.js +9 -1
  93. package/modules/lifecycle/db-relevance-flags.js +183 -0
  94. package/modules/lifecycle/db-versions.js +4 -3
  95. package/modules/lifecycle/db.js +19 -0
  96. package/modules/lifecycle/done-when.js +5 -3
  97. package/modules/lifecycle/est-advisory.js +31 -4
  98. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  99. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  100. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  101. package/modules/lifecycle/module.json +2 -1
  102. package/modules/lifecycle/routes/claims.js +8 -1
  103. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  104. package/modules/lifecycle/routes/goals.js +1 -1
  105. package/modules/lifecycle/routes/tasks.js +27 -24
  106. package/modules/lifecycle/routes/visuals.js +9 -0
  107. package/modules/lifecycle/ship-preflight.js +7 -11
  108. package/modules/lifecycle/task-visuals.js +109 -5
  109. package/modules/npm-release/module.json +2 -1
  110. package/modules/npm-release/preview/commands.js +81 -0
  111. package/modules/npm-release/preview/divert.js +95 -0
  112. package/modules/npm-release/preview/env.js +78 -0
  113. package/modules/npm-release/preview/proxy.js +107 -0
  114. package/modules/npm-release/preview/runtime.js +83 -0
  115. package/modules/npm-release/preview/supervisor.js +175 -0
  116. package/modules/npm-release/public/work.js +65 -1
  117. package/modules/npm-release/routes/preview.js +133 -0
  118. package/modules/provisioning/app-status.js +83 -0
  119. package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
  120. package/modules/provisioning/module.json +4 -2
  121. package/modules/provisioning/pollers/app-liveness.js +110 -0
  122. package/modules/provisioning/provisioning.js +5 -5
  123. package/modules/provisioning/render-lookup.js +68 -0
  124. package/modules/provisioning/render-standup.js +134 -0
  125. package/modules/provisioning/routes/render-standup.js +165 -0
  126. package/modules/provisioning/starter-bundles.js +4 -1
  127. package/modules/public-landing/public/account-private.states.json +14 -0
  128. package/modules/public-landing/public/account.html +513 -0
  129. package/modules/public-landing/public/account.probes.json +37 -0
  130. package/modules/public-landing/public/account.states.json +16 -0
  131. package/modules/public-landing/public/index.html +5 -2
  132. package/modules/public-landing/public/projects.html +661 -18
  133. package/modules/public-landing/public/projects.states.json +8 -1
  134. package/modules/render-deploy/deploys.js +203 -0
  135. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  136. package/modules/render-deploy/module.json +25 -0
  137. package/modules/render-deploy/public/deploy.css +10 -0
  138. package/modules/render-deploy/public/deploy.js +249 -0
  139. package/modules/render-deploy/render.js +56 -0
  140. package/modules/render-deploy/routes/act.js +88 -0
  141. package/modules/render-deploy/routes/app.js +65 -0
  142. package/modules/render-deploy/routes/history.js +38 -0
  143. package/modules/specialities/db.js +27 -8
  144. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  145. package/modules/specialities/routes/specialities.js +51 -3
  146. package/modules/specialities/skills.js +94 -0
  147. package/modules/specialities/specialities.js +56 -9
  148. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  149. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  150. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  151. package/modules/ui-design/kit/lib.js +3 -1
  152. package/modules/ui-design/kit/serve.js +96 -0
  153. package/package-lock.json +2 -2
  154. package/package.json +1 -1
  155. package/release-notes.json +173 -0
  156. package/scripts/gds/agents-sync.js +13 -3
  157. package/scripts/gds/autobongos-run.js +80 -2
  158. package/scripts/gds/autobongos-service.cmd +12 -0
  159. package/scripts/gds/copy-apply.js +14 -0
  160. package/scripts/gds/dev-box-guard.js +3 -2
  161. package/scripts/gds/fitness.js +9 -0
  162. package/scripts/gds/grade-correlation-audit.js +42 -12
  163. package/scripts/gds/grade-replay.js +1 -1
  164. package/scripts/gds/provision-core-upgrade.js +38 -7
  165. package/scripts/gds/provision-pin-key.js +176 -0
  166. package/scripts/gds/provision-render.js +189 -0
  167. package/scripts/gds/provision-teardown.js +9 -2
  168. package/scripts/gds/provision-units.js +31 -0
  169. package/scripts/gds/provision.js +12 -12
  170. package/scripts/gds/publish-manifest.js +1 -0
  171. package/scripts/gds/render-api.js +197 -0
  172. package/scripts/gds/render-payload.js +84 -0
  173. package/scripts/gds/run-unit-tests.js +10 -0
  174. package/scripts/gds/ship-finish.js +19 -20
  175. package/scripts/gds/smoke-dependencies.sh +37 -8
  176. package/scripts/gds/status.js +12 -4
  177. package/scripts/gds/upgrade.js +2 -2
  178. package/scripts/public-mirror-export.js +7 -1
  179. package/src/bongos/module-scope-map.js +13 -0
  180. package/src/bongos/serve-internal.js +3 -1
  181. package/src/module-api.js +10 -1
  182. package/src/platform-server.js +39 -0
  183. package/tests/account_privacy_flags.mjs +10 -6
  184. package/tests/agents_authoring.mjs +15 -1
  185. package/tests/agents_sync.mjs +106 -5
  186. package/tests/agents_validate.mjs +45 -0
  187. package/tests/api_path_404.mjs +6 -0
  188. package/tests/autobongos_cadence.mjs +7 -0
  189. package/tests/autobongos_loop.mjs +136 -1
  190. package/tests/autonomy_gauge.mjs +62 -0
  191. package/tests/blocker_hall_live_proof.mjs +275 -0
  192. package/tests/claim_gate_ci_unblock.mjs +116 -0
  193. package/tests/claim_gate_rebase.mjs +15 -3
  194. package/tests/collab_page.mjs +10 -0
  195. package/tests/conductor_main_e2e.mjs +97 -0
  196. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  197. package/tests/core_upgrade_runner.mjs +1 -0
  198. package/tests/dependency_writes_atomic.mjs +191 -0
  199. package/tests/deploy_page_projects.mjs +2 -1
  200. package/tests/effective_visibility_predicate.mjs +15 -0
  201. package/tests/est_advisory.mjs +27 -0
  202. package/tests/goal_map_page.mjs +26 -0
  203. package/tests/goal_task_relevance.mjs +518 -0
  204. package/tests/goal_working_area.mjs +146 -0
  205. package/tests/grade_attempts.mjs +61 -0
  206. package/tests/grade_attribution.mjs +23 -0
  207. package/tests/grade_correlation_audit.mjs +54 -1
  208. package/tests/grader_reader_lens.mjs +55 -2
  209. package/tests/grader_root_outage.mjs +6 -1
  210. package/tests/grader_subagent_tools_arg.mjs +102 -0
  211. package/tests/hall_approval_queue.mjs +54 -2
  212. package/tests/hall_settings_world.mjs +3 -1
  213. package/tests/hall_tweak_editor.mjs +316 -11
  214. package/tests/hub_account_page.mjs +394 -0
  215. package/tests/idea_detail_page.mjs +64 -0
  216. package/tests/idea_goal_ratification.mjs +64 -0
  217. package/tests/idea_spark_hall.mjs +60 -9
  218. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  219. package/tests/landing_page.mjs +6 -3
  220. package/tests/module-scope-map.mjs +31 -8
  221. package/tests/module_api.mjs +1 -0
  222. package/tests/module_loader.mjs +1 -1
  223. package/tests/nav_permission_atoms.mjs +1 -1
  224. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  225. package/tests/npm_release_preview_commands.mjs +89 -0
  226. package/tests/npm_release_preview_divert.mjs +164 -0
  227. package/tests/npm_release_preview_env.mjs +100 -0
  228. package/tests/npm_release_preview_proxy.mjs +162 -0
  229. package/tests/npm_release_preview_routes.mjs +208 -0
  230. package/tests/npm_release_preview_supervisor.mjs +203 -0
  231. package/tests/pin_write_key.mjs +383 -0
  232. package/tests/planning_session_skill.mjs +27 -0
  233. package/tests/platform_boot.mjs +71 -0
  234. package/tests/profile_nudge_links.mjs +86 -0
  235. package/tests/profile_ui_cross_project.mjs +1 -1
  236. package/tests/projects_hub.mjs +2 -2
  237. package/tests/projects_hub_app_status.mjs +249 -0
  238. package/tests/projects_hub_app_step.mjs +282 -52
  239. package/tests/projects_hub_render_connect.mjs +295 -0
  240. package/tests/provision_render.mjs +362 -0
  241. package/tests/provision_settings_apply.mjs +62 -0
  242. package/tests/provisioning_app_status.mjs +204 -0
  243. package/tests/provisioning_render_route.mjs +336 -0
  244. package/tests/provisioning_settings_apply.mjs +1 -1
  245. package/tests/provisioning_teardown_intent.mjs +2 -2
  246. package/tests/render_api.mjs +153 -0
  247. package/tests/render_check.mjs +2 -1
  248. package/tests/render_deploy.mjs +495 -0
  249. package/tests/ship_preflight.mjs +61 -8
  250. package/tests/smoke_dependencies_witness.mjs +46 -0
  251. package/tests/speciality_skills.mjs +214 -0
  252. package/tests/studio_room_height.mjs +70 -0
  253. package/tests/task_detail_includes.mjs +10 -0
  254. package/tests/task_visuals_instance_root.mjs +150 -0
  255. package/tests/tweak_batch_apply.mjs +21 -0
  256. package/tests/ui_design_kit.mjs +36 -4
  257. package/tests/upgrade.mjs +1 -1
  258. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -390,6 +390,36 @@ function upsertEnvVars(body, vars) {
390
390
  return text;
391
391
  }
392
392
 
393
+ // Env names a rename retired, carried forward ON THE BOX by the settings push (task
394
+ // 1004395). The prelaunch-gate rename (task 1003152, ADR 0182) made PRELAUNCH_PASSWORD
395
+ // primary and kept STEALTH_PASSWORD as an alias; the alias can only be dropped (task
396
+ // 1003830) once every live web.env carries the new name, and before this the only way
397
+ // to add it was the owner's SSH (blocker 1000147). [from, to] pairs.
398
+ const ENV_RENAMES = Object.freeze([['STEALTH_PASSWORD', 'PRELAUNCH_PASSWORD']]);
399
+
400
+ // Which renamed names does this web.env still hold ONLY under the old name? Returns
401
+ // { vars, carried }: `vars` is the new name → the old line's right-hand side, copied
402
+ // verbatim (quotes and all, so systemd reads the same value), for upsertEnvVars;
403
+ // `carried` names the pairs and is the ONLY part that may reach a log or an event —
404
+ // the value is a secret read on the box and it never leaves it. A new name that is
405
+ // already set wins, even when it differs. The last line of a key counts, as systemd
406
+ // reads it. PURE.
407
+ function renamedEnvCarry(body) {
408
+ const lines = String(body == null ? '' : body).split(/\r?\n/);
409
+ const rhs = (key) => {
410
+ let v = null;
411
+ for (const l of lines) if (l.startsWith(`${key}=`)) v = l.slice(key.length + 1);
412
+ return v;
413
+ };
414
+ const isSet = (v) => v !== null && !['', '""', "''"].includes(v.trim());
415
+ const vars = {}, carried = [];
416
+ for (const [from, to] of ENV_RENAMES) {
417
+ const oldV = rhs(from);
418
+ if (isSet(oldV) && !isSet(rhs(to))) { vars[to] = oldV; carried.push(`${from} -> ${to}`); }
419
+ }
420
+ return { vars, carried };
421
+ }
422
+
393
423
  // ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
394
424
  // (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
395
425
  // registration is a direct in-process call against the hub pool — no HTTP hop.
@@ -415,6 +445,7 @@ module.exports = {
415
445
  serviceUnitPath,
416
446
  settingsConsumed,
417
447
  settingsEnvVarsFor,
448
+ renamedEnvCarry,
418
449
  upsertEnvVars,
419
450
  webEnvBody,
420
451
  webEnvPath,
@@ -50,7 +50,7 @@ const crypto = require('node:crypto');
50
50
 
51
51
  const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
52
52
  const { accountPreflight, appGroupExpr, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
- const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
53
+ const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
54
54
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
55
55
  const { resolveProcessManager } = require('./provision-process.js');
56
56
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
@@ -374,10 +374,10 @@ async function provisionInstance(inst, deps) {
374
374
  // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
375
375
  if (inst.hosting_shape === 'cloud-host') {
376
376
  await scaffoldStandaloneRepo(inst, deps);
377
- // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
378
- // `git pull --ff-only origin main` — the pull that failed unauthenticated
379
- // on every private standup (walk finding N4; ADR 0176).
377
+ // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's `git pull
378
+ // --ff-only origin main`, which failed unauthenticated on every private standup (N4; ADR 0176).
380
379
  await ensurePrivateRepoAccess(inst, deps);
380
+ await require('./provision-pin-key.js').ensurePinWriteKey(inst, deps); // the hub's WRITE key for later pin pushes, public repos too (task 1004291, ADR 0350)
381
381
  }
382
382
  }
383
383
  if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
@@ -904,7 +904,7 @@ async function settingsApplyInstance(inst, deps) {
904
904
  // exists to avoid). An unreadable or empty file is refused, never "patched":
905
905
  // writing two lines over a missing PORT/PGDATABASE would take the instance down.
906
906
  const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
907
- const current = String((read && read.stdout) || '');
907
+ const current = String((read && read.stdout) || ''), carry = renamedEnvCarry(current); // task 1004395: only its NAMES are ever logged
908
908
  if (!apply) {
909
909
  log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
910
910
  pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
@@ -916,8 +916,8 @@ async function settingsApplyInstance(inst, deps) {
916
916
  return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
917
917
  }
918
918
  try {
919
- log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
920
- boxWriteFile(webEnvPath(inst), upsertEnvVars(current, settingsEnvVarsFor(inst, current)), { mode: 0o600, sudo: true });
919
+ log(` [config] patch the settings lines of web.env in place (creds + origins untouched)${carry.carried.length ? `; carry the renamed env forward: ${carry.carried.join(', ')} (value copied on the box, never shown)` : ''}`);
920
+ boxWriteFile(webEnvPath(inst), upsertEnvVars(current, { ...settingsEnvVarsFor(inst, current), ...carry.vars }), { mode: 0o600, sudo: true });
921
921
  log(' [service] restart so the memoized branding pack re-reads them');
922
922
  pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
923
923
  } catch (e) {
@@ -960,7 +960,7 @@ async function settingsApplyInstance(inst, deps) {
960
960
  if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'settings-apply').catch(() => {});
961
961
  await provisioning.recordEvent(db, {
962
962
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
963
- detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
963
+ detail: `settings pushed (${shown}) — web.env patched in place${carry.carried.length ? `; carried ${carry.carried.join(', ')}` : ''}; ${outcome}`,
964
964
  actor: 'runner',
965
965
  }).catch(() => {});
966
966
  log(healthy && consumed !== false
@@ -1285,7 +1285,7 @@ async function cmdRunIntents(deps) {
1285
1285
  // Dispatch EXPLICITLY per action — an unrecognized action must surface as an
1286
1286
  // error, never fall through to the destructive teardown leg (task 1002692).
1287
1287
  let result;
1288
- if (intent.action === 'provision') result = await provisionInstance(inst, deps);
1288
+ if (intent.action === 'provision') result = await require('./provision-render.js').afterProvision(inst, deps, intent, await provisionInstance(inst, deps)); // then the app on Render, when the wizard lent a key (task 1004352)
1289
1289
  else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
1290
1290
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1291
1291
  else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
@@ -1293,10 +1293,10 @@ async function cmdRunIntents(deps) {
1293
1293
  else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
1294
1294
  else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
1295
1295
  else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
1296
- else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
1296
+ else if (intent.action === 'repo-private' || intent.action === 'render-standup') result = intent.action === 'repo-private' ? await require('./provision-repo-private.js').repoPrivateInstance(inst, deps) : await require('./provision-render.js').renderStandupInstance(inst, deps, intent); // wire the deploy key, THEN flip (task 1004193) · spend the borrowed Render key once (task 1004183); required here — this file is at the size ratchet
1297
1297
  else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
1298
1298
  if (result && result.ok === false) {
1299
- if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
1299
+ if (intent.attempts >= MAX_INTENT_ATTEMPTS || result.terminal) { // terminal: a leg that must not be retried unattended (the Render leg's key is already gone — task 1004183)
1300
1300
  await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1301
1301
  // A retired intent stops silently otherwise (F19): record the reason
1302
1302
  // on the instance WITHOUT changing its lifecycle status — passing the
@@ -1489,7 +1489,7 @@ module.exports = {
1489
1489
  makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1490
1490
  domainAttachInstance, releasableDomains, cmdRunIntents, cmdReconcile, cmdStatus,
1491
1491
  // live settings push (task 1003140) + the in-place env patch + read-back it rides on
1492
- settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
1492
+ settingsApplyInstance, upsertEnvVars, renamedEnvCarry, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
1493
1493
  // restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
1494
1494
  restartInstance,
1495
1495
  // GitHub App Manifest flow (task 2080, ADR 0133)
@@ -72,6 +72,7 @@ const PUBLISH_ALLOWLIST = [
72
72
  'modules/provisioning/', // instance-hosting control plane — CORE (its do-api toolchain is 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.
73
73
  'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
74
74
  'modules/npm-release/', // the deploy page's "where is the work" + version history (task 1002622). default:false — on only in the hall that publishes the core. MUST be here: modules/hall-ui/ ships as one subtree, so a released core without this carries deploy.js's loader and the #nr-work mounts with no routes behind them — the copy-desk asymmetry above.
75
+ 'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
75
76
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
76
77
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
77
78
  'config/branding.neutral.json',
@@ -0,0 +1,197 @@
1
+ // scripts/gds/render-api.js — a thin Render API v1 client for the control plane, with
2
+ // the workspace (ownerId) guard (task 1004183; ADR 0327).
3
+ //
4
+ // Control-plane code, like its model scripts/gds/do-api.js: only the provisioning runner
5
+ // uses it, never the web tier (ADR 0111 §2). Same skeleton (JsonHttpApi): injectable
6
+ // transport, dry-run by default, and live mode refuses to start without a token.
7
+ //
8
+ // THE GUARD. A Render API key belongs to a USER, not a workspace, and carries that user's
9
+ // rights in every workspace they belong to — measured 2026-09-22: one owner's key reached
10
+ // three workspaces, one of them another builder's, holding live production services. So:
11
+ //
12
+ // - the client is built for ONE workspace (`ownerId`) and refuses to exist without it —
13
+ // never defaulted, and never derived by taking the first of GET /owners;
14
+ // - every call that names an owner sends this one;
15
+ // - every response that carries an owner is ASSERTED to carry this one, and a missing
16
+ // or different owner is a hard error (RenderOwnerMismatch). Assert on the response,
17
+ // not the request: a request you got right says nothing about a response that came
18
+ // from somewhere else. A list is checked element by element.
19
+ //
20
+ // Render's own shapes decide where the owner sits: a service carries `ownerId`, a
21
+ // Postgres carries `owner.id` (both measured by the pilot, task 1004142). A few calls
22
+ // answer with no owner at all (a database's connection-info, an env-var PUT, a deploy
23
+ // trigger). Those are only reachable through a method that first reads the parent
24
+ // resource through the guard, so nothing is ever sent to or read from a resource whose
25
+ // workspace was not checked.
26
+ //
27
+ // NO DELETE. The platform must not hold the ability to destroy infrastructure in an
28
+ // account it does not own (ADR 0327 §3). There is no delete method, and _req refuses the
29
+ // verb outright, so adding one later is a visible change to this file, not a one-liner.
30
+
31
+ 'use strict';
32
+
33
+ const { JsonHttpApi, makeApiError } = require('./http-api-client');
34
+
35
+ const RENDER_API_BASE = 'https://api.render.com/v1';
36
+
37
+ const RenderApiError = makeApiError('RenderApiError', 'RENDER_API_ERROR');
38
+ const RenderOwnerMismatch = makeApiError('RenderOwnerMismatch', 'RENDER_OWNER_MISMATCH');
39
+
40
+ // The owner id a Render resource carries, in either of the two shapes Render uses.
41
+ // null when it carries none.
42
+ function ownerOf(resource) {
43
+ if (!resource || typeof resource !== 'object') return null;
44
+ if (typeof resource.ownerId === 'string' && resource.ownerId) return resource.ownerId;
45
+ if (resource.owner && typeof resource.owner.id === 'string' && resource.owner.id) return resource.owner.id;
46
+ return null;
47
+ }
48
+
49
+ // Env var values are the owner's secrets on their way to Render. A dry-run log is the one
50
+ // place this client would otherwise print a request body, so it prints the names only.
51
+ function redact(body) {
52
+ if (!body || typeof body !== 'object') return body;
53
+ const out = { ...body };
54
+ if (Array.isArray(out.envVars)) out.envVars = out.envVars.map((v) => ({ key: v && v.key, value: '[redacted]' }));
55
+ if ('value' in out) out.value = '[redacted]';
56
+ return out;
57
+ }
58
+
59
+ class RenderApi extends JsonHttpApi {
60
+ // opts: { token, ownerId, dryRun=true, fetchImpl=globalThis.fetch, base, log }
61
+ constructor(opts = {}) {
62
+ super(opts, { base: RENDER_API_BASE, logTag: 'Render', ErrorClass: RenderApiError });
63
+ if (typeof opts.ownerId !== 'string' || !opts.ownerId.trim()) {
64
+ throw new RenderApiError('a Render client needs the workspace it acts in (ownerId) — it is never defaulted');
65
+ }
66
+ this.ownerId = opts.ownerId.trim();
67
+ if (!this.dryRun && !this.token) {
68
+ throw new RenderApiError('live Render call requested with no API key');
69
+ }
70
+ if (!this.dryRun && typeof this.fetchImpl !== 'function') {
71
+ throw new RenderApiError('no fetch implementation available for live mode');
72
+ }
73
+ }
74
+
75
+ async _req(method, path, body, opts = {}) {
76
+ if (method === 'DELETE') throw new RenderApiError(`refusing ${method} ${path}: the platform never deletes in an account it does not own (ADR 0327 §3)`);
77
+ if (this.dryRun) {
78
+ this.log(`[dry-run] Render ${method} ${path}${body ? ' ' + JSON.stringify(redact(body)) : ''}`);
79
+ return { dryRun: true, ...(opts.dryRunResult || {}) };
80
+ }
81
+ return super._req(method, path, body, opts);
82
+ }
83
+
84
+ // Throws unless `resource` carries exactly this client's workspace.
85
+ assertOwned(resource, what) {
86
+ const got = ownerOf(resource);
87
+ if (got !== this.ownerId) {
88
+ throw new RenderOwnerMismatch(
89
+ got
90
+ ? `Render answered ${what} from workspace ${got}, not ${this.ownerId} — refusing to go on`
91
+ : `Render answered ${what} with no workspace on it — refusing to go on`,
92
+ { body: null });
93
+ }
94
+ return resource;
95
+ }
96
+
97
+ // ---- workspaces ------------------------------------------------------------
98
+
99
+ // The workspaces the key can reach, as [{ id, name, type }]. Used for MEMBERSHIP only —
100
+ // "is the workspace the owner named one this key can act in" — never to pick one.
101
+ async listOwners() {
102
+ const json = await this._req('GET', '/owners?limit=100', null, {
103
+ dryRunResult: { items: [{ owner: { id: this.ownerId, name: '(dry-run)', type: 'team' } }] },
104
+ });
105
+ const items = Array.isArray(json) ? json : (json && json.items) || [];
106
+ return items.map((e) => (e && e.owner) || e).filter((o) => o && typeof o.id === 'string');
107
+ }
108
+
109
+ // ---- services --------------------------------------------------------------
110
+
111
+ async getService(serviceId) {
112
+ const json = await this._req('GET', `/services/${encodeURIComponent(serviceId)}`, null, {
113
+ dryRunResult: { id: serviceId, ownerId: this.ownerId, serviceDetails: { url: null } },
114
+ });
115
+ return this.assertOwned(json && json.service ? json.service : json, `service ${serviceId}`);
116
+ }
117
+
118
+ // payload: the POST /services body (render-payload.js). Its ownerId must be ours — the
119
+ // payload builder takes it as an argument, so this is checked, not overwritten.
120
+ async createService(payload) {
121
+ if (!payload || payload.ownerId !== this.ownerId) throw new RenderApiError('a service payload must name this client\'s workspace');
122
+ const json = await this._req('POST', '/services', payload, {
123
+ dryRunResult: { deployId: null, service: { id: `dryrun-srv-${payload.name}`, ownerId: this.ownerId, serviceDetails: { url: null } } },
124
+ });
125
+ return { deployId: (json && json.deployId) || null, service: this.assertOwned(json && json.service, 'the new service') };
126
+ }
127
+
128
+ // Set one env var on a service this workspace owns. The PUT answers with no owner, so
129
+ // the service is read through the guard first.
130
+ async putEnvVar(serviceId, key, value) {
131
+ await this.getService(serviceId);
132
+ return this._req('PUT', `/services/${encodeURIComponent(serviceId)}/env-vars/${encodeURIComponent(key)}`, { value }, { dryRunResult: { key } });
133
+ }
134
+
135
+ async triggerDeploy(serviceId) {
136
+ await this.getService(serviceId);
137
+ return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, {}, { dryRunResult: { id: null } });
138
+ }
139
+
140
+ // A service's deploys, newest first, as the bare deploy objects (Render wraps each in
141
+ // `{ deploy, cursor }`; both shapes are read). The list carries no owner, so the
142
+ // service is read through the guard first (the deploy hall's history, task 1004270).
143
+ async listDeploys(serviceId, { limit = 30 } = {}) {
144
+ await this.getService(serviceId);
145
+ const n = Math.max(1, Math.min(100, Number.parseInt(limit, 10) || 30));
146
+ const json = await this._req('GET', `/services/${encodeURIComponent(serviceId)}/deploys?limit=${n}`, null, { dryRunResult: { items: [] } });
147
+ const items = Array.isArray(json) ? json : (json && json.items) || [];
148
+ return items.map((e) => (e && e.deploy) || e).filter((d) => d && typeof d.id === 'string');
149
+ }
150
+
151
+ // Roll the service back to one of its earlier deploys. The answer carries no owner, so
152
+ // the service is read through the guard first. This is a write on the owner's account;
153
+ // the caller (the deploy hall) asks the owner twice before it reaches here.
154
+ async rollbackTo(serviceId, deployId) {
155
+ await this.getService(serviceId);
156
+ return this._req('POST', `/services/${encodeURIComponent(serviceId)}/rollback`, { deployId }, { dryRunResult: { id: null } });
157
+ }
158
+
159
+ // ---- Postgres --------------------------------------------------------------
160
+
161
+ async createPostgres(payload) {
162
+ if (!payload || payload.ownerId !== this.ownerId) throw new RenderApiError('a Postgres payload must name this client\'s workspace');
163
+ const json = await this._req('POST', '/postgres', payload, {
164
+ dryRunResult: { id: `dryrun-pg-${payload.name}`, status: 'creating', owner: { id: this.ownerId } },
165
+ });
166
+ return this.assertOwned(json, 'the new database');
167
+ }
168
+
169
+ async getPostgres(postgresId) {
170
+ const json = await this._req('GET', `/postgres/${encodeURIComponent(postgresId)}`, null, {
171
+ dryRunResult: { id: postgresId, status: 'available', owner: { id: this.ownerId } },
172
+ });
173
+ return this.assertOwned(json, `database ${postgresId}`);
174
+ }
175
+
176
+ // The database's connection strings. The answer carries no owner, so the database is
177
+ // read through the guard first.
178
+ async postgresConnectionInfo(postgresId) {
179
+ await this.getPostgres(postgresId);
180
+ return this._req('GET', `/postgres/${encodeURIComponent(postgresId)}/connection-info`, null, {
181
+ dryRunResult: { internalConnectionString: 'postgres://dry-run' },
182
+ });
183
+ }
184
+
185
+ // Poll until a new database is 'available'. `sleepFn` lets tests drive it with no clock.
186
+ async settlePostgres(postgresId, { tries = 60, sleepMs = 5000, sleepFn } = {}) {
187
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
188
+ for (let i = 0; i < tries; i++) {
189
+ const pg = await this.getPostgres(postgresId);
190
+ if (pg.status === 'available') return pg;
191
+ await sleep(sleepMs);
192
+ }
193
+ throw new RenderApiError(`database ${postgresId} did not become available in time`);
194
+ }
195
+ }
196
+
197
+ module.exports = { RenderApi, RenderApiError, RenderOwnerMismatch, RENDER_API_BASE, ownerOf, redact };
@@ -0,0 +1,84 @@
1
+ // scripts/gds/render-payload.js — WHAT the Render standup leg creates, kept apart from
2
+ // HOW it creates it (scripts/gds/provision-render.js) (task 1004183; retargeted by
3
+ // task 1004350).
4
+ //
5
+ // Pure functions, no I/O. What goes to the owner's Render account is their APP, not the
6
+ // Bongos hall: we host every project's hall, and the app deploys where its owner
7
+ // chooses (ADR 0345 D1, D3). So the service is built from the project's own repository
8
+ // and started the way that repository says — the owner's runtime and commands, or the
9
+ // repository's own Dockerfile — never the Bongos core.
10
+ //
11
+ // A database is optional (some apps need none). When there is one, its internal
12
+ // connection string is handed to the app as DATABASE_URL, the one name every mainstream
13
+ // framework reads. That is the only env var the platform sets: every other variable is
14
+ // the owner's to set on Render, and Bongos keeps their NAMES only (task 1004062's
15
+ // manifest) — never a value.
16
+ //
17
+ // The plans are per-project settings (ADR 0327 §5), never constants. The defaults are
18
+ // what the Render pilot (task 1004142) found enough at idle: a `starter` web service and
19
+ // a paid `basic_256mb` Postgres, because Render's free Postgres has no backups.
20
+
21
+ 'use strict';
22
+
23
+ // The vocabulary lives with the web tier's validation, so the two can never disagree.
24
+ const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES } = require('../../modules/provisioning/render-standup.js');
25
+ const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
26
+
27
+ // A Render resource name from the project's slug. Render allows letters, digits and
28
+ // dashes; the slug already is that, this only makes sure.
29
+ function resourceName(inst, suffix) {
30
+ const base = String(inst.slug || `project-${inst.id}`).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
31
+ return suffix ? `${base}-${suffix}` : base;
32
+ }
33
+
34
+ // The plan and region a project asked for, with the defaults filled in. A value outside
35
+ // the vocabulary is refused rather than passed through for Render to reject.
36
+ function plansFor(appHost) {
37
+ const h = appHost || {};
38
+ const out = {
39
+ webPlan: h.webPlan || DEFAULTS.webPlan,
40
+ dbPlan: h.dbPlan || DEFAULTS.dbPlan,
41
+ region: h.region || DEFAULTS.region,
42
+ };
43
+ if (!WEB_PLANS.includes(out.webPlan)) throw new Error(`unknown Render web plan '${out.webPlan}'`);
44
+ if (!DB_PLANS.includes(out.dbPlan)) throw new Error(`unknown Render database plan '${out.dbPlan}'`);
45
+ if (!REGIONS.includes(out.region)) throw new Error(`unknown Render region '${out.region}'`);
46
+ return out;
47
+ }
48
+
49
+ // How the app is built and started, as the owner said. `docker` builds the repository's
50
+ // own Dockerfile and needs no commands; every other runtime needs a start command, and a
51
+ // build command if the app has a build step.
52
+ function buildFor(appHost) {
53
+ const h = appHost || {};
54
+ if (!RUNTIMES.includes(h.runtime)) throw new Error(`unknown Render runtime '${h.runtime || 'none'}'`);
55
+ if (h.runtime === 'docker') return { runtime: 'docker' };
56
+ if (!h.startCommand) throw new Error('the app needs a start command');
57
+ return { runtime: h.runtime, buildCommand: h.buildCommand || null, startCommand: h.startCommand };
58
+ }
59
+
60
+ // POST /postgres. `plan` is never 'free' — see the header.
61
+ function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
62
+ return { name: resourceName(inst, 'db'), ownerId, plan: dbPlan, region, version: '16' };
63
+ }
64
+
65
+ // POST /services, for the owner's app from their repository. `dbUrl` is the database's
66
+ // internal connection string, or null when the app has no database. It is a VALUE sent
67
+ // to Render and held there — the platform keeps none.
68
+ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, build, dbUrl }) {
69
+ const envSpecificDetails = build.runtime === 'docker'
70
+ ? {}
71
+ : { ...(build.buildCommand ? { buildCommand: build.buildCommand } : {}), startCommand: build.startCommand };
72
+ return {
73
+ type: 'web_service',
74
+ name: resourceName(inst),
75
+ ownerId,
76
+ repo: repoUrl,
77
+ branch: branch || 'main',
78
+ autoDeploy: 'yes',
79
+ serviceDetails: { runtime: build.runtime, plan: webPlan, region, envSpecificDetails },
80
+ envVars: dbUrl ? [{ key: 'DATABASE_URL', value: dbUrl }] : [],
81
+ };
82
+ }
83
+
84
+ module.exports = { DEFAULTS, resourceName, plansFor, buildFor, renderPostgresPayload, renderServicePayload };
@@ -102,6 +102,13 @@ const INTEGRATION = new Set([
102
102
  // never was a dedicated consumer-layout-boot workflow, whatever older notes say.
103
103
  'consumer_layout_boot',
104
104
  'claim_gate_open_blocker', 'claim_gate_rebase',
105
+ // task 1003457: proves the blocker-resolve auto-promotion trigger (migration
106
+ // 009, tightened by core_223) fires on the HALL'S OWN write path — the real
107
+ // buildGdsRouter() HTTP routes, not a static SQL-text pin (blocker_resolve_guard.mjs,
108
+ // blocker_unlink.mjs) or a pure-predicate unit test (blocker_link.mjs, which never
109
+ // touches a database). Only a real transaction engine behind a real router proves
110
+ // that; self-skips without a reachable Postgres.
111
+ 'blocker_hall_live_proof',
105
112
  // task 1002709: core_221's re-keyed backstop index against a real Postgres.
106
113
  // The DB-free sibling pins the guard and the migration's text; this proves the
107
114
  // index actually refuses the commingling INSERT and still allows the parallel
@@ -122,6 +129,9 @@ const INTEGRATION = new Set([
122
129
  // dev box session. The claims foreign key that failed the live upgrade is a
123
130
  // schema fact only a transaction engine enforces. Always rolled back.
124
131
  'core_258_box_session_claims_db',
132
+ // task 1004360: core_262 drops the four builders columns migration 104 added, against a real
133
+ // Postgres, with the foreign key live, then re-runs it. Always rolled back.
134
+ 'core_262_drop_box_blocked_db',
125
135
  'discord_autojoin', 'discord_oauth',
126
136
  // task 1003000 (IC6): the three ideator credit streams against a real Postgres.
127
137
  // The three DB-free sibling suites stub the client and SIMULATE the
@@ -14,7 +14,8 @@ const { cliClient, printOnboardingNudge, rewardPolicy, cardFlagsFromMe } = requi
14
14
  const { closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
15
15
  const { syncMemoryOnShip } = require('./ship-memory-sync.js');
16
16
  const { uploadSessionOnShip } = require('./ship-session-upload.js');
17
- const { gitOk, shellOk } = require('./ship-git.js');
17
+ const { execFileSync } = require('node:child_process');
18
+ const { gitOk } = require('./ship-git.js');
18
19
  const { fetchDeployedVersion } = require('./ship-deploy-target.js');
19
20
  const { landBailed, reconciledCardStatus } = require('./ship-land.js');
20
21
  const { autoMerge, publishHeld } = require('./ship-merge.js');
@@ -142,18 +143,23 @@ async function onboardingNudgeBestEffort() {
142
143
  // close-out, folded into the delivery process (NOT a separate skill). After a
143
144
  // ship lands, scratch often lingers uncommitted — untracked files the preflight
144
145
  // lets through, generated artifacts, post-commit edits — making the session
145
- // hard to archive cleanly. This reports what's left and stashes it under a
146
- // labeled, RECOVERABLE stash so the tree is left clean + archive-ready and
147
- // nothing is lost. STRICTLY fail-open: the ship has already succeeded by the
148
- // time this runs, so nothing here may throw, prompt, or change the exit status —
149
- // a messy tree (or a stash failure) must never surface as a ship failure. The
150
- // caller gates this on a SUCCESSFUL land so a grade-failed 'completed' tree the
151
- // builder still needs to fix is never stashed out from under them.
146
+ // hard to archive cleanly. This REPORTS what's left and leaves it in place.
147
+ // task 1004254: it used to `git stash push -u` the leftover, but the stash stack
148
+ // is repo-global across every worktree (task 1003344), so one builder's scratch
149
+ // landed where a parallel agent's pop could apply it to the wrong branch — and
150
+ // it swallowed review renders an owner still needed. It never touches the stash.
151
+ // STRICTLY fail-open: the ship has already succeeded by the time this runs, so
152
+ // nothing here may throw, prompt, or change the exit status — a messy tree must
153
+ // never surface as a ship failure. The caller gates this on a SUCCESSFUL land.
152
154
  function postShipCloseout(taskId) {
153
155
  try {
154
- const out = gitOk('git status --porcelain');
155
- if (out == null) return; // git unavailable / not a repo — nothing to reconcile
156
- const plan = closeoutPlan(out, taskId);
156
+ // Untrimmed: gitOk's trim() eats the first line's leading status column, so
157
+ // " M a.txt" was listed as ".txt" (task 1004254).
158
+ let out;
159
+ try {
160
+ out = execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
161
+ } catch (_) { return; } // git unavailable / not a repo — nothing to reconcile
162
+ const plan = closeoutPlan(out);
157
163
  if (plan.clean) {
158
164
  console.log('Close-out: working tree clean — session is archive-ready.');
159
165
  return;
@@ -162,15 +168,7 @@ function postShipCloseout(taskId) {
162
168
  console.log(`Close-out: ${plan.leftover.length} uncommitted/untracked path(s) remain after the ship:`);
163
169
  for (const f of plan.leftover.slice(0, 15)) console.log(` ${f}`);
164
170
  if (plan.leftover.length > 15) console.log(` … and ${plan.leftover.length - 15} more`);
165
- // --include-untracked so scratch the preflight waved through is captured too.
166
- // Label is `otb-postship-<numericId>` — no shell metacharacters.
167
- const stashed = shellOk(`git stash push --include-untracked -m "${plan.stashLabel}"`);
168
- if (stashed) {
169
- console.log(`Close-out: stashed the leftover as "${plan.stashLabel}" — tree is now clean + archive-ready.`);
170
- console.log(' Recover anytime: git stash list → git stash pop');
171
- } else {
172
- console.log('Close-out: could not stash the leftover; left it in place (the ship already succeeded — tidy up manually when convenient).');
173
- }
171
+ console.log(`Close-out: left in place (task ${taskId} already shipped) — commit, move or delete them before archiving this worktree.`);
174
172
  } catch (err) {
175
173
  // Fail-open — a post-ship close-out problem must never look like a ship failure.
176
174
  console.log(`Close-out: skipped (${err && err.message ? err.message : 'error'}).`);
@@ -404,6 +402,7 @@ module.exports = {
404
402
  finishHeld,
405
403
  mergeAndDeploy,
406
404
  onboardingNudgeBestEffort,
405
+ postShipCloseout,
407
406
  postShipSync,
408
407
  printCompletionCard,
409
408
  };
@@ -10,9 +10,12 @@
10
10
  # 5. Promote A manually (the route allows it; the gate is at claim time)
11
11
  # 6. Try to claim A — still 409 DEPS_NOT_SHIPPED
12
12
  # 7. Claim B, ship B (active → completed → confirmed → shipped)
13
- # 8. Verify A auto-promoted backlog→ready (or stays ready if step 5 ran)
13
+ # 8. Verify C — a second dependent of B that NOTHING ever promoted by hand —
14
+ # flipped backlog→ready. This is the only assertion that can fail if the
15
+ # auto-promotion trigger is broken (task 1004191): A was already 'ready'
16
+ # from the manual step, so A's status proves nothing about the trigger.
14
17
  # 9. Claim A — succeeds
15
- # 10. Release A, abandon A (B is shipped and cannot be abandoned)
18
+ # 10. Release A, abandon A and C (B is shipped and cannot be abandoned)
16
19
  #
17
20
  # Run from any environment with a valid CLI session (/builder-setup).
18
21
  # Requires curl.
@@ -44,7 +47,22 @@ A=$(curl -sS -X POST "$BASE/api/bongos/tasks" \
44
47
  A_ID=$(echo "$A" | python3 -c "import json,sys; print(json.load(sys.stdin)['task']['id'])")
45
48
  echo " A id=$A_ID"
46
49
 
47
- echo "=== 3. Wire A → depends_on B ==="
50
+ echo "=== 2b. Create task C (dependent, status=backlog, NEVER promoted by hand) ==="
51
+ # task 1004191 (finding 62 of the task 1000379 audit): A is promoted manually in
52
+ # step 4, so "A is ready after B ships" held whether or not the auto-promotion
53
+ # trigger fired. C exists to be the dependent only the trigger can flip.
54
+ C=$(curl -sS -X POST "$BASE/api/bongos/tasks" \
55
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
56
+ --data "{\"version_id\":\"GDS-V3\",\"title\":\"__smoke__ dep-test C $SUFFIX\",\"description\":\"smoke\",\"status\":\"backlog\",\"kind\":\"feature\",\"discipline\":\"engineering\",\"priority\":5,\"touches\":[\"smoke-dep-C-$SUFFIX/\"],\"source\":\"smoke\",\"source_ref\":\"smoke-dep-C-$SUFFIX\"}")
57
+ C_ID=$(echo "$C" | python3 -c "import json,sys; print(json.load(sys.stdin)['task']['id'])")
58
+ echo " C id=$C_ID"
59
+
60
+ echo "=== 3. Wire A → depends_on B, and C → depends_on B ==="
61
+ curl -sS -X POST "$BASE/api/bongos/tasks/$C_ID/dependencies" \
62
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
63
+ --data "{\"depends_on_task_id\": $B_ID}" >/dev/null
64
+ C_PRE=$(curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/bongos/tasks/$C_ID" | python3 -c "import json,sys; print(json.load(sys.stdin)['task']['status'])")
65
+ [ "$C_PRE" = "backlog" ] && echo " PASS: C is backlog before B ships" || (echo " FAIL: C is '$C_PRE' before B ships — the auto-promotion check below would prove nothing"; exit 1)
48
66
  curl -sS -X POST "$BASE/api/bongos/tasks/$A_ID/dependencies" \
49
67
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
50
68
  --data "{\"depends_on_task_id\": $B_ID}" | python3 -m json.tool
@@ -69,15 +87,18 @@ curl -sS -X POST "$BASE/api/bongos/claims/by-task/$B_ID/resolve" \
69
87
  curl -sS -X POST "$BASE/api/bongos/tasks/$B_ID/confirm" -H "Authorization: Bearer $TOKEN" >/dev/null
70
88
  curl -sS -X POST "$BASE/api/bongos/tasks/$B_ID/ship" -H "Authorization: Bearer $TOKEN" >/dev/null
71
89
 
72
- echo "=== 7. Verify A auto-promoted (status should still be 'ready' from manual step 4, or auto-flipped from backlog) ==="
73
- A_STATUS=$(curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/bongos/tasks/$A_ID" | python3 -c "import json,sys; print(json.load(sys.stdin)['task']['status'])")
74
- echo " A status: $A_STATUS"
75
- [ "$A_STATUS" = "ready" ] && echo " PASS" || (echo " FAIL: A not ready"; exit 1)
90
+ echo "=== 7. Verify the auto-promotion trigger fired: C backlog → ready ==="
91
+ # C was never promoted by hand, so only promote_dependents_on_ship can have made
92
+ # it 'ready'. (A is 'ready' from the manual step 4 regardless, so A is not asserted
93
+ # here — that is the assertion that used to pass with the trigger broken.)
94
+ C_STATUS=$(curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/bongos/tasks/$C_ID" | python3 -c "import json,sys; print(json.load(sys.stdin)['task']['status'])")
95
+ echo " C status: $C_STATUS"
96
+ [ "$C_STATUS" = "ready" ] && echo " PASS: C auto-promoted" || (echo " FAIL: C is '$C_STATUS' — B shipped but the auto-promotion trigger did not flip its dependent"; exit 1)
76
97
 
77
98
  echo "=== 8. Claim A — must succeed now ==="
78
99
  node scripts/gds/claim.js "$A_ID" --worktree "smoke-$SUFFIX-A"
79
100
 
80
- echo "=== 9. Cleanup — abandon A; B is shipped and stays ==="
101
+ echo "=== 9. Cleanup — abandon A and C; B is shipped and stays ==="
81
102
  # task 1004026: this step used to ANNOUNCE "release A; mark both abandoned" and then
82
103
  # only release A. Neither task reached a terminal state, so every run leaked two
83
104
  # __smoke__ rows into the live tasks table — invisibly, because anyone grepping the
@@ -108,6 +129,14 @@ case "${A_CLEANUP_CODE:-000}" in
108
129
  000) echo " SKIP cleanup — POST /tasks/$A_ID/abandon got no response; task #$A_ID left in the ledger" ;;
109
130
  *) echo " SKIP cleanup — POST /tasks/$A_ID/abandon returned HTTP $A_CLEANUP_CODE; task #$A_ID left in the ledger" ;;
110
131
  esac
132
+ C_CLEANUP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
133
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
134
+ --data '{"reason":"smoke cleanup (task 1004191) - auto-promotion witness; the throwaway row has served its purpose"}' \
135
+ "$BASE/api/bongos/tasks/$C_ID/abandon") || true
136
+ case "${C_CLEANUP_CODE:-000}" in
137
+ 2*) echo " PASS cleaned up throwaway task #$C_ID (abandoned)" ;;
138
+ *) echo " SKIP cleanup — POST /tasks/$C_ID/abandon returned HTTP ${C_CLEANUP_CODE:-000}; task #$C_ID left in the ledger" ;;
139
+ esac
111
140
  echo " NOTE task #$B_ID is SHIPPED and is deliberately left in the ledger — the abandon route refuses shipped work (cannot_abandon_shipped)."
112
141
 
113
142
  echo