@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
@@ -588,6 +588,176 @@ await ta('scaffoldStandaloneRepo: no owner token → falls back to the SSH origi
588
588
  }
589
589
  });
590
590
 
591
+ // ── ADOPT vs GREENFIELD onboarding (ADR 0121, task 1002562) ───────────────────────
592
+ // The runner used to run the greenfield scaffold unconditionally: `git init` a fresh tree
593
+ // and push HEAD:main. Against a repo that already has history that is an unrelated-history
594
+ // non-fast-forward, so adopting an existing repo could never succeed.
595
+
596
+ t('onboardMode: adopt only when the row says so; anything else → greenfield (the SAFE default)', () => {
597
+ assert.equal(P.onboardMode({ onboard_mode: 'adopt' }), 'adopt');
598
+ assert.equal(P.onboardMode({ onboard_mode: 'greenfield' }), 'greenfield');
599
+ // A row written BEFORE provisioning_004 added the column, and any unrecognized value,
600
+ // must resolve to greenfield — which refuses to push over existing history — never to
601
+ // adopt, which would start layering onto a repo nobody declared as pre-existing.
602
+ assert.equal(P.onboardMode({}), 'greenfield');
603
+ assert.equal(P.onboardMode({ onboard_mode: null }), 'greenfield');
604
+ assert.equal(P.onboardMode({ onboard_mode: 'ADOPT' }), 'greenfield');
605
+ assert.equal(P.onboardMode({ onboard_mode: 'something-else' }), 'greenfield');
606
+ assert.equal(P.onboardMode(null), 'greenfield');
607
+ });
608
+
609
+ t('alreadyScaffolded: the marker is mode-dependent — package.json alone does NOT mean an adopt is done', () => {
610
+ const dir = mkdtempSync(join(tmpdir(), 'marker-'));
611
+ try {
612
+ assert.equal(P.alreadyScaffolded(dir, 'greenfield'), false, 'no package.json → not scaffolded');
613
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
614
+
615
+ // An ordinary existing Node project: package.json present, no core dep. Greenfield's
616
+ // marker would call this "done" — for ADOPT that is the bug this guards, because the
617
+ // layering would be skipped on the very first run and the instance never become bootable.
618
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'their-app', dependencies: { react: '^18' } }));
619
+ assert.equal(P.alreadyScaffolded(dir, 'greenfield'), true, 'greenfield: package.json IS the marker (unchanged)');
620
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false, 'adopt: an existing project is NOT already adopted');
621
+
622
+ // Once the layer has added the core dep, adopt IS done. Both scope aliases count.
623
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { react: '^18', '@bongos/core': 'file:vendor/x.tgz' } }));
624
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), true);
625
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@cloudbongos/core': '^1.0.0' } }));
626
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), true, 'the @cloudbongos alias counts too');
627
+
628
+ // Unparseable → NOT already done, so the adopt layer runs and reports it, rather than
629
+ // silently skipping on a file it could not read.
630
+ writeFileSync(join(dir, 'package.json'), '{ not json');
631
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
632
+ } finally {
633
+ rmSync(dir, { recursive: true, force: true });
634
+ }
635
+ });
636
+
637
+ // Drive the scaffold in a given mode against a fresh base and return the joined argv.
638
+ async function scaffoldInMode(mode, { token = null, seedRoot = null } = {}) {
639
+ const base = mkdtempSync(join(tmpdir(), `mode-${mode}-`));
640
+ const origBase = P.CONFIG.standaloneBase;
641
+ P.CONFIG.standaloneBase = base;
642
+ try {
643
+ if (seedRoot) { mkdirSync(join(base, 'demo'), { recursive: true }); seedRoot(join(base, 'demo')); }
644
+ const { deps, cmds } = recorder({ apply: true });
645
+ const writes = [];
646
+ deps.writeFile = (p, body, opts) => { writes.push({ p, body, opts }); return { ok: true }; };
647
+ if (token) deps.getGithubToken = async () => ({ token, scope: 'read:user,repo' });
648
+ await P.scaffoldStandaloneRepo(
649
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: mode },
650
+ { ...deps, instanceRoot: base },
651
+ );
652
+ return { joined: cmds.join('\n'), cmds, writes, root: join(base, 'demo') };
653
+ } finally {
654
+ P.CONFIG.standaloneBase = origBase;
655
+ rmSync(base, { recursive: true, force: true });
656
+ }
657
+ }
658
+
659
+ await ta('scaffoldStandaloneRepo(adopt): CLONES the existing repo instead of git-init-ing a fresh tree', async () => {
660
+ const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
661
+ assert.match(joined, /clone https:\/\/x-access-token@github\.com\/lars589\/demo\.git/, 'clones over the token URL (covers a PRIVATE repo the shared deploy key cannot read)');
662
+ assert.ok(!/git -C \S+ init -q/.test(joined), 'never git-inits a fresh tree on the adopt path — that is what produced the unrelated history');
663
+ assert.ok(!/checkout -B main/.test(joined), 'does not force-create the branch over the cloned one');
664
+ assert.match(joined, /init\.js --from .* --no-seed --adopt --accept /, 'materializes via init --adopt --accept (layering, accept-gated)');
665
+ // origin must end up as the SSH remote, like greenfield leaves it: the ongoing deploy
666
+ // (`git pull --ff-only origin main`, and the box timer's `git reset --hard origin/main`)
667
+ // reads origin, and the one-shot token URL cannot authenticate once its credential file
668
+ // is deleted.
669
+ assert.match(joined, /git -C \S+ remote set-url origin git@github-deploy:lars589\/demo\.git/, 'normalizes origin to the SSH remote after cloning');
670
+ });
671
+
672
+ // THE SAFETY PROPERTY. Since ADR 0155 this credential can write to EVERY repo the owner
673
+ // owns, so a --force here would be a route to destroying a user's work. A rejected push is
674
+ // the correct, recoverable outcome.
675
+ await ta('scaffoldStandaloneRepo(adopt): NEVER force-pushes and never passes --force to init', async () => {
676
+ const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
677
+ assert.ok(!/--force/.test(joined), 'no --force anywhere in the adopt argv');
678
+ assert.ok(!/ -f\b/.test(joined.replace(/rm -f /g, '')), 'no -f force shorthand (rm -f of the credential file excepted)');
679
+ assert.ok(!/\+refs|\+HEAD|:\+/.test(joined), 'no leading-+ refspec (the other way to force a push)');
680
+ assert.match(joined, /push https:\/\/x-access-token@github\.com\/lars589\/demo\.git HEAD:main/, 'a plain fast-forward push');
681
+ });
682
+
683
+ await ta('scaffoldStandaloneRepo(greenfield): unchanged — git init + --force, no clone', async () => {
684
+ const { joined } = await scaffoldInMode('greenfield', { token: 'gho_TOK' });
685
+ assert.match(joined, /git -C \S+ init -q/, 'still git-inits the empty repo');
686
+ assert.match(joined, /checkout -B main/);
687
+ assert.match(joined, /init\.js --from .* --no-seed --force /, 'greenfield keeps --force (safe on an empty repo)');
688
+ assert.ok(!/--adopt/.test(joined), 'no --adopt on the greenfield path');
689
+ assert.ok(!/ clone /.test(joined), 'nothing to clone from an empty repo');
690
+ });
691
+
692
+ await ta('scaffoldStandaloneRepo(adopt): no owner token → clones over the SSH remote (no credential helper)', async () => {
693
+ const { joined } = await scaffoldInMode('adopt'); // recorder default getGithubToken → null
694
+ assert.match(joined, /clone git@github-deploy:lars589\/demo\.git/, 'falls back to the SSH remote');
695
+ assert.ok(!/credential\.helper=/.test(joined), 'no credential helper without a token');
696
+ assert.match(joined, /push -u origin main/, 'and the SSH origin push');
697
+ });
698
+
699
+ await ta('scaffoldStandaloneRepo(adopt): does NOT clobber an existing .gitignore — appends only what the deploy needs', async () => {
700
+ const { writes } = await scaffoldInMode('adopt', {
701
+ token: 'gho_TOK',
702
+ seedRoot: (root) => writeFileSync(join(root, '.gitignore'), 'their-secrets/\ncoverage/\n'),
703
+ });
704
+ const gi = writes.find((w) => /\.gitignore$/.test(w.p));
705
+ assert.ok(gi, 'wrote .gitignore');
706
+ assert.match(String(gi.body), /their-secrets\//, 'the owner’s existing rules survive');
707
+ assert.match(String(gi.body), /coverage\//);
708
+ assert.match(String(gi.body), /node_modules\//, 'and the entries `npm ci` + the deploy require are added');
709
+ });
710
+
711
+ await ta('scaffoldStandaloneRepo(adopt): a default branch that is not `main` fails LOUDLY, not silently', async () => {
712
+ // The rest of provisioning is main-only (git pull --ff-only origin main; the box deploy
713
+ // does git reset --hard origin/main), so layering onto another branch would push somewhere
714
+ // the deploy never reads — a half-working instance with no error.
715
+ const base = mkdtempSync(join(tmpdir(), 'mode-branch-'));
716
+ const origBase = P.CONFIG.standaloneBase;
717
+ P.CONFIG.standaloneBase = base;
718
+ try {
719
+ const { deps } = recorder({ apply: true });
720
+ deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
721
+ deps.exec = (cmd) => ({ ok: true, stdout: /rev-parse --abbrev-ref HEAD/.test(cmd) ? 'master\n' : '' });
722
+ await assert.rejects(
723
+ () => P.scaffoldStandaloneRepo(
724
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
725
+ { ...deps, instanceRoot: base },
726
+ ),
727
+ /default branch is "master", not "main"/,
728
+ );
729
+ } finally {
730
+ P.CONFIG.standaloneBase = origBase;
731
+ rmSync(base, { recursive: true, force: true });
732
+ }
733
+ });
734
+
735
+ await ta('scaffoldStandaloneRepo(adopt): the credential file is removed even when a git step THROWS', async () => {
736
+ const base = mkdtempSync(join(tmpdir(), 'mode-cred-'));
737
+ const origBase = P.CONFIG.standaloneBase;
738
+ P.CONFIG.standaloneBase = base;
739
+ try {
740
+ const { deps, cmds } = recorder({ apply: true });
741
+ deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
742
+ deps.exec = (cmd) => {
743
+ cmds.push(cmd);
744
+ if (/ push /.test(cmd)) throw new Error('remote rejected: non-fast-forward');
745
+ return { ok: true, stdout: '' };
746
+ };
747
+ await assert.rejects(
748
+ () => P.scaffoldStandaloneRepo(
749
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
750
+ { ...deps, instanceRoot: base },
751
+ ),
752
+ /non-fast-forward/,
753
+ );
754
+ assert.match(cmds.join('\n'), /rm -f [^\n]*bongos-gitcred-demo/, 'a failed push must not leave the token on disk');
755
+ } finally {
756
+ P.CONFIG.standaloneBase = origBase;
757
+ rmSync(base, { recursive: true, force: true });
758
+ }
759
+ });
760
+
591
761
  await ta('provisionInstance is idempotent: an already-active instance is a no-op', async () => {
592
762
  const { deps, cmds } = recorder();
593
763
  const r = await P.provisionInstance({ ...coTenant, status: 'active' }, deps);
@@ -0,0 +1,180 @@
1
+ // tests/provisioning_onboard_mode.mjs
2
+ //
3
+ // The onboard_mode leg of brownfield onboarding (ADR 0121, task 1002562, migration
4
+ // provisioning_004). Before this, greenfield-vs-adopt existed ONLY as a query param on
5
+ // GET /provisioning/onboard-plan that chose which checklist TEXT to render — it was never
6
+ // persisted, so the control-plane runner ran the greenfield scaffold unconditionally and
7
+ // adopting an existing repo always failed as an unrelated-history non-fast-forward.
8
+ //
9
+ // Covers the two layers that carry the mode from the browser to the runner:
10
+ // • createInstance — normalization (unknown/absent → greenfield) + what reaches SQL
11
+ // • POST /instances — validation + threading, driven over a real mounted router
12
+ // • publicInstance — the mode is readable back out (coalesced for pre-004 rows)
13
+ //
14
+ // DB-free: the pool is a recording fake and the data-access fns the route needs are
15
+ // stubbed on the cached module object. No network.
16
+ //
17
+ // Run: node tests/provisioning_onboard_mode.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import http from 'node:http';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = new URL('..', import.meta.url).pathname;
25
+
26
+ const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
27
+
28
+ let passed = 0, failed = 0;
29
+ function t(name, fn) {
30
+ try { fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ // ── createInstance: normalization + what actually reaches SQL ─────────────────────
39
+ // A recording pool: capture the INSERT's params so we assert the STORED value, not just
40
+ // the returned row (a normalization bug that only shows up in the column is the one that
41
+ // would silently mislabel a row forever).
42
+ function recordingDb(returnRow = {}) {
43
+ const calls = [];
44
+ return {
45
+ calls,
46
+ query: async (text, params) => {
47
+ calls.push({ text, params });
48
+ return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo', ...returnRow }] };
49
+ },
50
+ };
51
+ }
52
+
53
+ await ta('createInstance: onboardMode "adopt" is persisted', async () => {
54
+ const db = recordingDb();
55
+ await provisioning.createInstance(db, {
56
+ ownerBuilderId: 7, slug: 'demo', targetRef: 'lars589/demo',
57
+ hostingShape: 'standalone', onboardMode: 'adopt',
58
+ });
59
+ const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
60
+ assert.ok(insert, 'issued an INSERT');
61
+ assert.match(insert.text, /onboard_mode/, 'the column is in the INSERT');
62
+ assert.ok(insert.params.includes('adopt'), `'adopt' reached the INSERT params (got ${JSON.stringify(insert.params)})`);
63
+ });
64
+
65
+ await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE default', async () => {
66
+ // Greenfield refuses to push over existing history (no --force anywhere), so an
67
+ // unrecognized value must land there. Defaulting to 'adopt' would put the layering path
68
+ // on a repo nobody declared as pre-existing.
69
+ for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
70
+ const db = recordingDb();
71
+ await provisioning.createInstance(db, {
72
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
73
+ });
74
+ const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
75
+ assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
76
+ assert.ok(!insert.params.includes('adopt'), `onboardMode=${JSON.stringify(mode)} must NOT become adopt`);
77
+ }
78
+ });
79
+
80
+ t('ONBOARD_MODES matches the provisioning_004 CHECK constraint', () => {
81
+ assert.deepEqual(provisioning.ONBOARD_MODES, ['greenfield', 'adopt']);
82
+ });
83
+
84
+ t('publicInstance: surfaces onboard_mode, coalescing a pre-004 row to greenfield', () => {
85
+ assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: 'adopt' }).onboard_mode, 'adopt');
86
+ // A row read before the column existed (or a NULL) reads as greenfield — which is what
87
+ // it was, by construction: there was no other scaffold path.
88
+ assert.equal(provisioning.publicInstance({ id: 1 }).onboard_mode, 'greenfield');
89
+ assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: null }).onboard_mode, 'greenfield');
90
+ });
91
+
92
+ // ── POST /provisioning/instances over a real mounted router ───────────────────────
93
+ let lastCreateArgs = null;
94
+ provisioning.createInstance = async (_db, args) => {
95
+ lastCreateArgs = args;
96
+ return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, onboard_mode: args.onboardMode }, created: true };
97
+ };
98
+ provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
99
+ provisioning.recordEvent = async () => {};
100
+ provisioning.getInstanceBySlug = async () => null;
101
+
102
+ const api = require(`${ROOT}/src/module-api.js`);
103
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
104
+
105
+ const express = require('express');
106
+ const provisioningRoutes = require(`${ROOT}/modules/provisioning/routes/provisioning.js`);
107
+ const app = express();
108
+ app.use(express.json());
109
+ app.use((_req, res, next) => {
110
+ res.fail = (code, opts, details) => {
111
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
112
+ res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
113
+ };
114
+ next();
115
+ });
116
+ app.use('/api/bongos', provisioningRoutes());
117
+
118
+ const server = app.listen(0);
119
+ await new Promise((r) => server.once('listening', r));
120
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
121
+
122
+ function post(path, body) {
123
+ return new Promise((resolve, reject) => {
124
+ const payload = JSON.stringify(body);
125
+ const req = http.request(`${base}${path}`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
128
+ }, (res) => {
129
+ let d = '';
130
+ res.on('data', (c) => { d += c; });
131
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
132
+ });
133
+ req.on('error', reject);
134
+ req.end(payload);
135
+ });
136
+ }
137
+
138
+ await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
139
+ lastCreateArgs = null;
140
+ const res = await post('/provisioning/instances', {
141
+ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', onboard_mode: 'adopt',
142
+ });
143
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
144
+ assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
145
+ });
146
+
147
+ await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
148
+ lastCreateArgs = null;
149
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone' });
150
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
151
+ assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
152
+ });
153
+
154
+ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently coerced)', async () => {
155
+ // Coercing here would be worse than refusing: the caller believes it asked for one thing
156
+ // and the runner does another. The route names the valid set so the client can fix itself.
157
+ const res = await post('/provisioning/instances', {
158
+ slug: 'demo', hosting_shape: 'standalone', target_ref: 'a/b', onboard_mode: 'brownfield',
159
+ });
160
+ assert.equal(res.status, 400);
161
+ assert.equal(res.body.error.code, 'bad_onboard_mode');
162
+ assert.deepEqual(res.body.error.valid, ['greenfield', 'adopt']);
163
+ });
164
+
165
+ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no repo to layer onto', async () => {
166
+ // Without target_ref the runner would fall back to greenfield behavior on a row labelled
167
+ // adopt. Refuse at the door rather than store a lie.
168
+ for (const body of [
169
+ { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt' },
170
+ { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt', target_ref: ' ' },
171
+ ]) {
172
+ const res = await post('/provisioning/instances', body);
173
+ assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
174
+ assert.equal(res.body.error.code, 'target_ref_required_for_adopt');
175
+ }
176
+ });
177
+
178
+ server.close();
179
+ console.log(`\nprovisioning_onboard_mode: ${passed} passed, ${failed} failed`);
180
+ process.exit(failed ? 1 : 0);
@@ -211,6 +211,9 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
211
211
  const offenders = []; // …and are still publishable (the actual failure)
212
212
  for (const name of fs.readdirSync(testsDir)) {
213
213
  if (!/\.(mjs|js|cjs)$/.test(name)) continue;
214
+ // Skip self: the regex specimens below are deliberate full import forms;
215
+ // this file's real imports pull scripts/gds code only, never modules/.
216
+ if (name === 'publish_manifest.mjs') continue;
214
217
  const rel = `tests/${name}`;
215
218
  const bad = new Set();
216
219
  let m; IMPORT_RE.lastIndex = 0;
@@ -225,9 +228,19 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
225
228
  offenders.push(`${rel} imports excluded module(s) [${[...bad].sort().join(', ')}] but is still PUBLISHABLE — add it to CONTENT_EXCLUDE (task 2147)`);
226
229
  }
227
230
  }
228
- // non-vacuous: the scan must actually SEE the known host tests (a broken regex that
229
- // matched nothing would otherwise pass silently). The 7 game/art tests are the floor.
230
- assert.ok(detected.length >= 7, `expected ≥7 host-module tests detected, saw ${detected.length} — the import scan may be broken`);
231
+ // non-vacuous: prove the regex itself extracts a module key from every import
232
+ // form it claims to cover. A repo-state floor ("must see ≥7 host tests") went
233
+ // stale the moment the host-module tests moved to modules/<key>/tests/ under
234
+ // ADR 0107 — tests/ can legitimately contain zero live specimens (task 1002611).
235
+ for (const [specimen, key] of [
236
+ [`const x = require('../modules/game/db.js')`, 'game'],
237
+ [`import x from '../../modules/art-pipeline/pipeline/palette.js'`, 'art-pipeline'],
238
+ [`const y = await import('./modules/some-key/loader.js')`, 'some-key'],
239
+ ]) {
240
+ IMPORT_RE.lastIndex = 0;
241
+ const m = IMPORT_RE.exec(specimen);
242
+ assert.ok(m && (m[2] || m[4]) === key, `import scan no longer matches: ${specimen}`);
243
+ }
231
244
  assert.equal(offenders.length, 0, '\n ' + offenders.join('\n '));
232
245
  });
233
246
 
@@ -58,7 +58,7 @@ function mkDeps({
58
58
  refreshStaleImpl,
59
59
  healAttempts = new Map(),
60
60
  } = {}) {
61
- const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], logs: [], errors: [] };
61
+ const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
62
62
  // isSafeBranch mirrors github-push's pattern enough for the test (rejects
63
63
  // option-like / traversal refs); we use the REAL module so the contract can't drift.
64
64
  const githubPush = require('../modules/lifecycle/github-push.js');
@@ -108,23 +108,33 @@ function mkDeps({
108
108
  return !!a;
109
109
  },
110
110
  // task 1547 per-task land proof. Mirrors the real github-push.landProvenForTask
111
- // shape but routes its ONE network hop (the ancestry compare) through the mock
111
+ // shape but routes its network hops (the ancestry compares) through the mock
112
112
  // above so the sweep stays DB/network-free, and reuses the REAL prReferencesTask
113
113
  // so that pure check can't drift. publishedSha (the server-pushed tip) is the
114
- // robust proof; the matched-PR-references-this-task fallback covers a legacy /
115
- // in-flight row with no recorded tip.
114
+ // robust proof; task 1002615 adds the merged-PR merge-commit proof (out-of-band /
115
+ // squash land) and gates the no-tip reference fallback on merged:true, because
116
+ // the sweep now consults this proof even while a PR is still in flight.
116
117
  landProvenForTask: async ({ taskId, publishedSha, status }) => {
117
- if (publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha))) {
118
+ const hasTip = !!(publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha)));
119
+ if (hasTip) {
118
120
  calls.ancestry.push(publishedSha);
119
121
  const a = ancestryBySha[publishedSha];
120
122
  if (a instanceof Error) throw a;
121
- return a
122
- ? { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` }
123
- : { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
123
+ if (a) return { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` };
124
124
  }
125
- return githubPush.prReferencesTask(status, taskId)
125
+ if (status && status.merged === true && githubPush.prReferencesTask(status, taskId)
126
+ && status.merge_sha && /^[0-9a-f]{7,64}$/.test(String(status.merge_sha))) {
127
+ calls.ancestry.push(status.merge_sha);
128
+ const m = ancestryBySha[status.merge_sha];
129
+ if (m instanceof Error) throw m;
130
+ if (m) return { proven: true, reason: `merged PR for task ${taskId} — merge in main (out-of-band/squash land)` };
131
+ }
132
+ if (hasTip) {
133
+ return { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
134
+ }
135
+ return (status && status.merged === true && githubPush.prReferencesTask(status, taskId))
126
136
  ? { proven: true, reason: `matched PR references task ${taskId}` }
127
- : { proven: false, reason: `no published tip and matched PR does not reference task ${taskId}` };
137
+ : { proven: false, reason: `no published tip and no merged PR references task ${taskId}` };
128
138
  },
129
139
  },
130
140
  // task.shipped is emitted on the kernel seam now; the discord module's listener
@@ -133,6 +143,9 @@ function mkDeps({
133
143
  // broadcastImpl REPLACES the default push (the throwing-broadcast test relies
134
144
  // on that to prove the reconcile stands even when the listener throws).
135
145
  emit: async (event, task) => {
146
+ // task 1002606: record EVERY seam event so the strand push channel
147
+ // (task.strand-flagged) is observable alongside task.shipped.
148
+ calls.events.push({ event, payload: task });
136
149
  if (event !== 'task.shipped') return;
137
150
  if (broadcastImpl) return broadcastImpl(task);
138
151
  calls.broadcast.push(task.id);
@@ -307,6 +320,50 @@ test('1002472 merged + deploy unconfigured but tip NOT in main → NO ship (1547
307
320
  assert.equal(deps._calls.ship.length, 0);
308
321
  });
309
322
 
323
+ // ---------------------------------------------------------------------------
324
+ // task 1002615 (idea 1000613) — a land made OUT-OF-BAND must still ship.
325
+ // The 2026-07-29 reproduction: ship → confirmed → PR merged by the owner through
326
+ // the web UI → the work verified on main → the task sat at 'confirmed' for 9+ min
327
+ // (forever), builder unpaid, until a manual POST /ship.
328
+ // ---------------------------------------------------------------------------
329
+ const MERGE_SHA = 'c'.repeat(40);
330
+
331
+ test('1002615 web-UI squash merge (tip rewritten, PR merged) → ships via the merge-commit proof', async () => {
332
+ const deps = mkDeps({
333
+ rows: [{ task_id: 80, builder_id: 3, worktree_name: 'wt-squash', published_head_sha: NOT_ON_MAIN }],
334
+ statusByBranch: { 'claude/wt-squash': { ...landedPrFor(80), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
335
+ ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
336
+ });
337
+ const r = await reconciler.runSweep(deps);
338
+ assert.equal(r.reconciled, 1, 'a squash-rewritten land must not strand unpaid');
339
+ assert.deepEqual(deps._calls.ship, [{ taskId: 80, builderId: 3 }]);
340
+ assert.deepEqual(deps._calls.broadcast, [80]);
341
+ });
342
+
343
+ test('1002615 manual git merge to main, PR closed un-merged → tip ancestry ships it (prNotLanded no longer suppresses the proof)', async () => {
344
+ const deps = mkDeps({
345
+ rows: [{ task_id: 81, builder_id: 3, worktree_name: 'wt-oob', published_head_sha: ON_MAIN,
346
+ confirmed_at: new Date().toISOString() }],
347
+ statusByBranch: { 'claude/wt-oob': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'pending' } },
348
+ ancestryBySha: { [ON_MAIN]: true },
349
+ });
350
+ const r = await reconciler.runSweep(deps);
351
+ assert.equal(r.reconciled, 1, 'work on main behind a dead PR must still land');
352
+ assert.deepEqual(deps._calls.ship, [{ taskId: 81, builderId: 3 }]);
353
+ });
354
+
355
+ test('1002615 the reuse guard holds: merged PR for a DIFFERENT task + tip not in main → NO ship', async () => {
356
+ const deps = mkDeps({
357
+ rows: [{ task_id: 82, builder_id: 3, worktree_name: 'wt-reused', published_head_sha: NOT_ON_MAIN,
358
+ confirmed_at: new Date().toISOString() }],
359
+ statusByBranch: { 'claude/wt-reused': { ...landedPrFor(999), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
360
+ ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
361
+ });
362
+ const r = await reconciler.runSweep(deps);
363
+ assert.equal(r.reconciled, 0, 'the merge-commit proof must bind to THIS task');
364
+ assert.equal(deps._calls.ship.length, 0);
365
+ });
366
+
310
367
  test('getPublishStatus throws (transient) → swallowed, sweep continues to the next task', async () => {
311
368
  const deps = mkDeps({
312
369
  rows: [
@@ -906,6 +963,120 @@ test('1002461 the WARN re-arms once a credential appears and then disappears aga
906
963
  assert.equal(badAgain._calls.errors.length, 1, 'a credential LOSS after a healthy period must warn again');
907
964
  });
908
965
 
966
+ // ---------------------------------------------------------------------------
967
+ // task 1002606 (finding P-3, the DETECTION half) — a strand reaches its builder
968
+ // when it is DETECTED, not at the 30-minute stale WARN. Definitive shapes flag
969
+ // needs_rebase on the sweep that sees them AND emit task.strand-flagged (the
970
+ // push channel Discord listens on); ambiguous shapes keep the 30-min grace.
971
+ // ---------------------------------------------------------------------------
972
+
973
+ test('1002606 a hand-written conflict flags needs_rebase IMMEDIATELY (not at the 30-min WARN), with the file list', async () => {
974
+ const deps = mkDeps({
975
+ rows: [{ task_id: 500, builder_id: 6, worktree_name: 'wt-hand', title: 'x', security_sensitive: false,
976
+ confirmed_at: new Date().toISOString() }], // FRESH — no stale grace involved
977
+ mergeByBranch: { 'claude/wt-hand': { merged: false, merged_now: false, number: 500, reason: 'merge_conflict' } },
978
+ conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['src/bongos/db.js'] }),
979
+ });
980
+ const r = await reconciler.runSweep(deps);
981
+ assert.equal(deps._calls.flag.length, 1, 'flagged on THIS sweep, minutes after the conflict — not 30 later');
982
+ assert.equal(deps._calls.flag[0].taskId, 500);
983
+ assert.match(deps._calls.flag[0].reason, /^strand:unresolvable_conflict:src\/bongos\/db\.js/, 'the reason names the conflicted file so /builder-start says WHERE');
984
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 500/.test(m) && /hand-written conflict/.test(m)), 'WARN\'d with the concrete cause');
985
+ assert.equal(r.strandsFlagged, 1);
986
+ assert.equal(r.reconciled, 0, 'a conflicted PR still never ships');
987
+ });
988
+
989
+ test('1002606 every NEW needs_rebase flag emits task.strand-flagged (the push channel)', async () => {
990
+ const deps = mkDeps({
991
+ rows: [{ task_id: 501, builder_id: 6, worktree_name: 'wt-hand2', title: 'the title', security_sensitive: true,
992
+ confirmed_at: new Date().toISOString() }],
993
+ mergeByBranch: { 'claude/wt-hand2': { merged: false, merged_now: false, number: 501, reason: 'merge_conflict' } },
994
+ conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['a.js'] }),
995
+ });
996
+ await reconciler.runSweep(deps);
997
+ const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
998
+ assert.equal(strandEvents.length, 1, 'exactly one push event per new flag');
999
+ assert.equal(strandEvents[0].payload.id, 501);
1000
+ assert.equal(strandEvents[0].payload.builder_id, 6);
1001
+ assert.equal(strandEvents[0].payload.security_sensitive, true, 'the security flag rides along so the listener can route safely');
1002
+ assert.match(strandEvents[0].payload.reason, /^strand:unresolvable_conflict/);
1003
+ });
1004
+
1005
+ test('1002606 an ALREADY-flagged strand does not re-emit (flagTaskNeedsRebase idempotence gates the event)', async () => {
1006
+ const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
1007
+ const deps = mkDeps({
1008
+ rows: [{ task_id: 502, builder_id: 6, worktree_name: null, published_head_sha: null, confirmed_at: old }],
1009
+ });
1010
+ deps.db.flagTaskNeedsRebase = async () => false; // already flagged on a prior sweep
1011
+ await reconciler.runSweep(deps);
1012
+ assert.equal(deps._calls.events.filter((e) => e.event === 'task.strand-flagged').length, 0,
1013
+ 'a re-sweep of a flagged strand must not spam the channel');
1014
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 502/.test(m) && /needs_rebase already set/.test(m)),
1015
+ 'the WARN line still fires for journal visibility');
1016
+ });
1017
+
1018
+ test('1002606 a PR CLOSED without merging is DEFINITIVE — flagged fresh, no 30-min grace', async () => {
1019
+ const deps = mkDeps({
1020
+ rows: [{ task_id: 503, builder_id: 8, worktree_name: 'wt-deadpr', published_head_sha: NOT_ON_MAIN,
1021
+ confirmed_at: new Date().toISOString() }], // FRESH
1022
+ statusByBranch: { 'claude/wt-deadpr': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
1023
+ ancestryBySha: { [NOT_ON_MAIN]: false }, // and the work is NOT on main out-of-band
1024
+ });
1025
+ const r = await reconciler.runSweep(deps);
1026
+ assert.deepEqual(deps._calls.flag, [{ taskId: 503, reason: 'strand:pr_closed_unmerged' }],
1027
+ 'a dead PR will never land on its own — the builder is told now');
1028
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 503/.test(m) && /closed without merging/.test(m)));
1029
+ assert.equal(r.strandsFlagged, 1);
1030
+ assert.equal(r.staleStrands, 0, 'not an AGED strand — this is the immediate path');
1031
+ });
1032
+
1033
+ test('1002606 the 1002615 out-of-band land still wins over the dead-PR flag (proof runs first)', async () => {
1034
+ // Same closed-unmerged PR shape, but the work IS on main — it must ship, not flag.
1035
+ const deps = mkDeps({
1036
+ rows: [{ task_id: 504, builder_id: 8, worktree_name: 'wt-oob2', published_head_sha: ON_MAIN,
1037
+ confirmed_at: new Date().toISOString() }],
1038
+ statusByBranch: { 'claude/wt-oob2': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
1039
+ ancestryBySha: { [ON_MAIN]: true },
1040
+ });
1041
+ const r = await reconciler.runSweep(deps);
1042
+ assert.equal(r.reconciled, 1, 'landed work behind a dead PR ships (1002615)');
1043
+ assert.equal(deps._calls.flag.length, 0, 'a landed task is never flagged');
1044
+ });
1045
+
1046
+ test('1002606 an OPEN PR still gets the grace — fresh, not flagged (CI may be legitimately running)', async () => {
1047
+ const deps = mkDeps({
1048
+ rows: [{ task_id: 505, builder_id: 8, worktree_name: 'wt-open2', confirmed_at: new Date().toISOString() }],
1049
+ statusByBranch: { 'claude/wt-open2': PENDING },
1050
+ });
1051
+ const r = await reconciler.runSweep(deps);
1052
+ assert.equal(deps._calls.flag.length, 0, 'an in-flight open PR is not a definitive strand');
1053
+ assert.equal(r.strandsFlagged, 0);
1054
+ });
1055
+
1056
+ test('1002606 a MERGED PR mid-deploy keeps the grace (merged=true never hits the dead-PR path)', async () => {
1057
+ const deps = mkDeps({
1058
+ rows: [{ task_id: 506, builder_id: 8, worktree_name: 'wt-middeploy', published_head_sha: ON_MAIN,
1059
+ confirmed_at: new Date().toISOString() }],
1060
+ statusByBranch: { 'claude/wt-middeploy': { pr_state: 'closed', merged: true, merge_sha: 'cafe1234', deploy_status: 'pending' } },
1061
+ ancestryBySha: { [ON_MAIN]: true },
1062
+ });
1063
+ const r = await reconciler.runSweep(deps);
1064
+ assert.equal(deps._calls.flag.length, 0, 'a merge whose deploy is finishing is the NORMAL landing window');
1065
+ assert.equal(r.reconciled, 0, 'the deploy gate still governs (1002615 exception intact)');
1066
+ });
1067
+
1068
+ test('1002606 the aged stale strand now ALSO emits task.strand-flagged', async () => {
1069
+ const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
1070
+ const deps = mkDeps({
1071
+ rows: [{ task_id: 507, builder_id: 9, worktree_name: null, published_head_sha: null, confirmed_at: old }],
1072
+ });
1073
+ await reconciler.runSweep(deps);
1074
+ const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
1075
+ assert.equal(strandEvents.length, 1);
1076
+ assert.equal(strandEvents[0].payload.id, 507);
1077
+ assert.match(strandEvents[0].payload.reason, /^strand:no_branch_no_tip/);
1078
+ });
1079
+
909
1080
  // Drain the microtask queue, then report + exit with the right code.
910
1081
  setTimeout(() => {
911
1082
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);