@bongos/core 1.19.650 → 1.19.651

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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.650",
6
- "core_contract": "1.19.650",
7
- "source_commit": "5616186b29fea16f71cec70846cda88fe124c8c7",
5
+ "core_version": "1.19.651",
6
+ "core_contract": "1.19.651",
7
+ "source_commit": "c13a88d638588dedd2454579a62cb76e4415656a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T20:01:10.390Z",
9
+ "built_at": "2026-09-10T20:32:32.558Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 471,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2604,
20
- "tree_sha256": "39e08e82e8577f3b084821c1496216c5420ba3297223ffd477c047ea6f1a8e39",
20
+ "tree_sha256": "3e626f458ea19a08ab773ec27d6efe07ab6901a906091aa433ac27aa7789173e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -2777,7 +2777,7 @@
2777
2777
  {
2778
2778
  "path": "docs/module-api-changelog.md",
2779
2779
  "mode": "0000644",
2780
- "sha256": "6d296bf6c8df69fc9ec84f3a6548039ef304b369a93d7ac2c4c80bc2ba86842f"
2780
+ "sha256": "b19f014bb424a4974164c08f892cb0103af216bebd411615b1d997f01d6054a7"
2781
2781
  },
2782
2782
  {
2783
2783
  "path": "docs/modules-contract.md",
@@ -7732,12 +7732,12 @@
7732
7732
  {
7733
7733
  "path": "package-lock.json",
7734
7734
  "mode": "0000644",
7735
- "sha256": "8ac99c68b8d148e7e3a53cbcb94d2e2bd10f6d95e87bd173b2fc6b9497e7fe8e"
7735
+ "sha256": "e70fe3cda6fcf43f7416f03c0232be6be8ff84fca2aaac504dac9268b33d8f69"
7736
7736
  },
7737
7737
  {
7738
7738
  "path": "package.json",
7739
7739
  "mode": "0000644",
7740
- "sha256": "8dc1331a5f582689d16c699861f729674dfcb8d02485a285ff71c46428a6d010"
7740
+ "sha256": "17a361e96b81d29aaddc6b2f73d2d3cdf007914df8e14d6f3fcd0ea7c12378c8"
7741
7741
  },
7742
7742
  {
7743
7743
  "path": "public-docs/index.html",
@@ -8902,7 +8902,7 @@
8902
8902
  {
8903
8903
  "path": "scripts/gds/ship-land.js",
8904
8904
  "mode": "0000644",
8905
- "sha256": "72efd4268060fb5ddf8a176d91baf01353dc1e82594d70feef029c45f748d8f9"
8905
+ "sha256": "d8cd184816230b7b246ec7afaa2ea285b7ba0e24bb8f47f79d2bc7fa5b423429"
8906
8906
  },
8907
8907
  {
8908
8908
  "path": "scripts/gds/ship-memory-sync.js",
@@ -8912,7 +8912,7 @@
8912
8912
  {
8913
8913
  "path": "scripts/gds/ship-merge.js",
8914
8914
  "mode": "0000644",
8915
- "sha256": "7c5751c146bcddc9cbf88c6177925e049413d0d275ddb174d5d92b38e75ecab8"
8915
+ "sha256": "b602b4cf8f0e66610991237e1cb7ade9cd5c14659b155ce3512268d40ae670eb"
8916
8916
  },
8917
8917
  {
8918
8918
  "path": "scripts/gds/ship-preflight-steps.js",
@@ -8952,7 +8952,7 @@
8952
8952
  {
8953
8953
  "path": "scripts/gds/ship.js",
8954
8954
  "mode": "0000644",
8955
- "sha256": "a612a450145476ea8d6705d7a45b8a84e2dc861ad8cb5be8c6aa69252b5c54c5"
8955
+ "sha256": "6fd0329b8ad7d45fbb93deda02142c2b766c9e01b669d2d94eeafcb1caa225b0"
8956
8956
  },
8957
8957
  {
8958
8958
  "path": "scripts/gds/skill-lint.js",
@@ -9492,7 +9492,7 @@
9492
9492
  {
9493
9493
  "path": "src/module-api.js",
9494
9494
  "mode": "0000644",
9495
- "sha256": "1d0148cf06b94938cb6f7e6a9e6666ab5393092309530ce5efa6afbd363323b5"
9495
+ "sha256": "de52ed5ea35350aff9ade88ecc06393252ef5294ea7b76aca8222f956367472a"
9496
9496
  },
9497
9497
  {
9498
9498
  "path": "src/module-loader/catalog.js",
@@ -12552,7 +12552,7 @@
12552
12552
  {
12553
12553
  "path": "tests/ship_ci_deploy.mjs",
12554
12554
  "mode": "0000644",
12555
- "sha256": "e236bc3d6882bc6ddf5d389371a92a96aec62769f14c37bbca31810bcbea0d61"
12555
+ "sha256": "83f8bfb0979b8950a8895eae7710981c19e30878b89fe648eb2dc2e8098c9de1"
12556
12556
  },
12557
12557
  {
12558
12558
  "path": "tests/ship_error_shape.mjs",
@@ -1749,5 +1749,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1749
1749
  landed since 1.19.648 with no explicit bump. run 34523111338. (task 1002620)
1750
1750
  1.19.650 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1751
1751
  landed since 1.19.649 with no explicit bump. run 34523753119. (task 1002620)
1752
+ 1.19.651 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1753
+ landed since 1.19.650 with no explicit bump. run 34526936330. (task 1002620)
1752
1754
  ---------------------------------------------------------------------------
1753
1755
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.650",
3
+ "version": "1.19.651",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.650",
9
+ "version": "1.19.651",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.650",
3
+ "version": "1.19.651",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -35,9 +35,37 @@ const { normalizeApiError } = require('./ship-io.js');
35
35
  // Only landBailed makes the card say NOT landed. Purely additive: no bail site
36
36
  // changes what it DOES, only what it now reports back.
37
37
  // ADR 0184: an EMPTY ship has no commit to prove, so it must DECLARE itself.
38
+ // task 1002572 (PART 2): a bail also carries an optional `bailId` — the stable
39
+ // name of WHICH refusal fired, so a caller can route the recoverable ones to the
40
+ // push+PR path instead of stranding. `null` for every un-tagged bail, which keeps
41
+ // them terminal (fail-safe: an untagged bail never gains a silent fallback).
38
42
  function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
39
43
  function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
40
- function landBailed(reason, nextStep = null) { return { ok: false, pending: false, reason, nextStep }; }
44
+ function landBailed(reason, nextStep = null, bailId = null) { return { ok: false, pending: false, reason, nextStep, bailId }; }
45
+
46
+ // ---------- which bails a PR can still land (task 1002572, part 2) ----------
47
+ //
48
+ // A builder in a SINGLE main checkout can never complete a local merge: the tree
49
+ // that would RECEIVE the merge is the very tree holding the feature branch. That
50
+ // is a property of the machine, not of the work — the branch itself is perfectly
51
+ // landable, just not from here. So that one bail must fall back to the push+PR
52
+ // path (ciLand) rather than parking the task at 'confirmed' unpaid (ADR 0120).
53
+ //
54
+ // Everything else stays LOUD. A merge conflict, a red post-merge smoke, a failed
55
+ // push, an unreachable origin: those are real failures, and a PR would carry the
56
+ // same breakage to main. Membership is opt-in by id for exactly that reason —
57
+ // blanket-converting bails into PRs is the way this fix goes wrong.
58
+ const LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH = 'main-checkout-on-branch';
59
+ const LAND_BAIL_NO_TASK_BRANCH = 'no-task-branch';
60
+ const PR_RECOVERABLE_BAILS = new Set([LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH]);
61
+
62
+ // landBailRecoverableByPr — PURE. Can this bail still be landed by opening a PR?
63
+ // `no-task-branch` is deliberately NOT in the set: standing on main there is no
64
+ // head branch to open a PR FROM, so the PR path has nothing to offer it (its cure
65
+ // is to go back to the task branch and re-ship, which now works).
66
+ function landBailRecoverableByPr(bailId) {
67
+ return PR_RECOVERABLE_BAILS.has(bailId);
68
+ }
41
69
 
42
70
  // The short COMMAND form of strandNextStep (which is prose, sized for the log).
43
71
  // The card has room for one command, not a sentence.
@@ -639,6 +667,11 @@ module.exports = {
639
667
  ciLand,
640
668
  classifyLandOutcome,
641
669
  formatPublishFailure,
670
+ // task 1002572 (PART 2): the bail ids + the pure "can a PR still land this?"
671
+ // decision, so the single-checkout fallback is opt-in per bail and unit-tested.
672
+ LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
673
+ LAND_BAIL_NO_TASK_BRANCH,
674
+ landBailRecoverableByPr,
642
675
  landBailed,
643
676
  landPending,
644
677
  landPendingLines,
@@ -1,10 +1,14 @@
1
1
  // scripts/gds/ship-merge.js — autoMerge — the merge/regen/push/land chain that puts the branch on main.
2
2
  //
3
3
  // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
- // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
- // original ship.js lines 2307-2579 unchanged. ship.js remains the facade that
6
- // re-exports every name and owns the CLI entry point, so the ship contract and
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite. ship.js remains the facade
5
+ // that re-exports every name and owns the CLI entry point, so the ship contract and
7
6
  // every flag are untouched, and the other scripts that import from it are too.
7
+ //
8
+ // task 1002572 (PART 2) split the entry point in two: `mergeLocally` is the original
9
+ // merge/regen/push/deploy chain, and `autoMerge` is a thin wrapper that routes the one
10
+ // bail a PR can still land — a single checkout whose main worktree holds the feature
11
+ // branch — into the push+PR path instead of stranding the task at 'confirmed'.
8
12
 
9
13
  'use strict';
10
14
 
@@ -13,7 +17,14 @@ const { cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-prefli
13
17
  const mergeLock = require('../../modules/lifecycle/merge-lock');
14
18
  const { buildMergeArgv, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
15
19
  const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolveDeployTarget, safeBranding, shouldBailOnFetchFailure } = require('./ship-deploy-target.js');
16
- const { ciLand, landBailed, landed } = require('./ship-land.js');
20
+ const {
21
+ LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
22
+ LAND_BAIL_NO_TASK_BRANCH,
23
+ ciLand,
24
+ landBailRecoverableByPr,
25
+ landBailed,
26
+ landed,
27
+ } = require('./ship-land.js');
17
28
  const { runSmoke } = require('./ship-grade.js');
18
29
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
19
30
  const { REPO_ROOT } = require('./ship-state.js');
@@ -23,7 +34,49 @@ function zeroCommitVerdict(commitsAhead, dbOnly) {
23
34
  return dbOnly ? 'db-only' : 'refuse';
24
35
  }
25
36
 
26
- async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
37
+ // onMainNextStep PURE. The recovery command for the other half of the
38
+ // single-checkout catch-22 (task 1002572): the builder is standing ON main, so
39
+ // there is no head branch to merge FROM and none to open a PR from either. Since
40
+ // mergeLocally's sibling bail now falls back to the PR path, shipping from the
41
+ // task branch WORKS even in a single checkout — so the cure is "go back to the
42
+ // branch and re-ship", not the circular /merge-mode this used to advise. Only
43
+ // name the branch when it actually exists locally; otherwise fall back to
44
+ // /merge-mode rather than inventing a checkout target.
45
+ function onMainNextStep(taskId, taskBranchExists) {
46
+ return taskBranchExists
47
+ ? `git checkout task-${taskId} && node scripts/gds/ship.js ${taskId}`
48
+ : '/merge-mode';
49
+ }
50
+
51
+ // localTaskBranchExists — does this checkout still hold the task's own branch? The
52
+ // discriminator onMainNextStep needs, so the advice only names a branch that is
53
+ // really there (a builder who deleted it gets /merge-mode, not a failing checkout).
54
+ function localTaskBranchExists(taskId) {
55
+ return gitArgvOk(['rev-parse', '--verify', '--quiet', `refs/heads/task-${taskId}`], { stdio: 'ignore' });
56
+ }
57
+
58
+ // autoMerge — the land entry point. Runs the local merge chain, then (task 1002572
59
+ // PART 2) routes the ONE bail a PR can still land into the push+PR path instead of
60
+ // leaving the task at 'confirmed'. The fallback deliberately sits OUTSIDE
61
+ // mergeLocally: its `finally` releases the shared merge lock and normalizes the main
62
+ // worktree, and the PR poll that follows can run for minutes — holding the lock
63
+ // across it would wedge every other ship on this machine.
64
+ // `deps` is a test seam ONLY — the routing is the whole deliverable and it cannot
65
+ // be reached from a unit test otherwise (mergeLocally needs a real second checkout
66
+ // and an authed gh). Callers pass nothing; the defaults are the real functions.
67
+ async function autoMerge(taskId, valueSummary, opts = {}, deps = {}) {
68
+ const merge = deps.mergeLocally || mergeLocally;
69
+ const land = deps.ciLand || ciLand;
70
+ const branchOf = deps.currentBranch || currentBranch;
71
+ const outcome = await merge(taskId, valueSummary, opts);
72
+ if (!landBailRecoverableByPr(outcome && outcome.bailId)) return outcome;
73
+ const branch = branchOf();
74
+ console.log(' auto-merge: a local merge is impossible from this checkout, but the branch is still landable —');
75
+ console.log(' falling back to the push+PR land path. Nothing is stranded; the server lands the PR.');
76
+ return await land(taskId, branch, valueSummary);
77
+ }
78
+
79
+ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
27
80
  const branch = currentBranch();
28
81
  if (!branch || branch === 'main' || branch === 'HEAD') {
29
82
  // task 1002723: an --allow-empty / --db-only ship legitimately runs FROM the
@@ -40,8 +93,15 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
40
93
  console.log(` auto-merge: skipped (current branch is ${branch})`);
41
94
  // The merge source IS the current branch, so on main there is nothing to
42
95
  // merge. Half of the single-checkout catch-22 this task documents: the other
43
- // half is the "main worktree is on <branch>, not main" bail below.
44
- return landBailed(`nothing to merge this checkout is on ${branch}, not a task branch`, '/merge-mode');
96
+ // half is the "main worktree is on <branch>, not main" bail below, which now
97
+ // falls back to a PR. This half CANNOT a PR needs a head branch and there
98
+ // is none here — so it stays a bail, but with a next step that actually
99
+ // recovers instead of pointing back at the same dead end (task 1002572).
100
+ return landBailed(
101
+ `nothing to merge — this checkout is on ${branch}, not a task branch`,
102
+ onMainNextStep(taskId, localTaskBranchExists(taskId)),
103
+ LAND_BAIL_NO_TASK_BRANCH,
104
+ );
45
105
  }
46
106
 
47
107
  // Refresh the data-driven diagrams before computing commits-ahead, so a
@@ -153,14 +213,17 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
153
213
  // The main worktree must currently have main checked out.
154
214
  const onMain = gitOk('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT });
155
215
  if (onMain !== 'main') {
156
- console.log(` auto-merge: main worktree is on ${onMain}, not main; task stays at confirmed`);
216
+ console.log(` auto-merge: main worktree is on ${onMain}, not main a local merge is impossible here`);
157
217
  // The other half of the single-checkout catch-22: the merge target tree is
158
218
  // the one holding the feature branch, so there is nowhere to merge INTO.
159
- // Name the cure, not just the symptom this is the bail that used to be
160
- // invisible behind "landing automatically".
219
+ // task 1002572 (PART 2): this is a property of the MACHINE, not of the work
220
+ // the branch lands fine via a PR. Tagged PR-recoverable so autoMerge falls
221
+ // back instead of parking the task at 'confirmed' unpaid. The reason +
222
+ // nextStep are kept for the case where the fallback itself bails.
161
223
  return landBailed(
162
224
  `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`,
163
225
  '/merge-mode',
226
+ LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
164
227
  );
165
228
  }
166
229
 
@@ -293,5 +356,8 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
293
356
 
294
357
  module.exports = {
295
358
  autoMerge,
359
+ // task 1002572 (PART 2): the pure recovery command for a ship run from main —
360
+ // exported so the "don't point back at the dead end" wording is pinned.
361
+ onMainNextStep,
296
362
  zeroCommitVerdict,
297
363
  };
@@ -105,6 +105,9 @@ const {
105
105
  const {
106
106
  classifyLandOutcome,
107
107
  formatPublishFailure,
108
+ LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
109
+ LAND_BAIL_NO_TASK_BRANCH,
110
+ landBailRecoverableByPr,
108
111
  landBailed,
109
112
  landPending,
110
113
  landPendingLines,
@@ -140,7 +143,7 @@ const {
140
143
  isLinkifiableDocPath,
141
144
  parseDeletedUpstreamPaths,
142
145
  } = require('./ship-preflight-steps.js');
143
- const { zeroCommitVerdict } = require('./ship-merge.js');
146
+ const { onMainNextStep, zeroCommitVerdict } = require('./ship-merge.js');
144
147
  const {
145
148
  buildGradePayload,
146
149
  buildPriorRoundContext,
@@ -303,6 +306,15 @@ module.exports = {
303
306
  // ship unless --db-only is explicitly passed. Exported pure so the gate is
304
307
  // unit-tested (has-commits / db-only / refuse) without git.
305
308
  zeroCommitVerdict,
309
+ // task 1002572 (PART 2): the single-checkout land fallback, pure halves only.
310
+ // landBailRecoverableByPr is the whole decision — which auto-merge bail a PR can
311
+ // still land (the main checkout holding the branch) versus which are REAL
312
+ // failures a PR would only carry to main. onMainNextStep is the recovery command
313
+ // for the other half of the catch-22, where no head branch exists to PR from.
314
+ LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
315
+ LAND_BAIL_NO_TASK_BRANCH,
316
+ landBailRecoverableByPr,
317
+ onMainNextStep,
306
318
  // touches[] cleanup [3/8] (task 878): folder-tag derivation, now over the
307
319
  // committed diff — exported pure so the regression test can assert it.
308
320
  deriveTagsFromPaths,
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.650'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.651'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -44,7 +44,7 @@ function test(name, fn) {
44
44
  }
45
45
  }
46
46
 
47
- const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus, landed, landPending, landBailed, strandCommand, landPendingLines } = ship;
47
+ const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus, landed, landPending, landBailed, strandCommand, landPendingLines, landBailRecoverableByPr, onMainNextStep, LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH, LAND_BAIL_NO_TASK_BRANCH } = ship;
48
48
 
49
49
  // ---- 1. parseDeployMode precedence + safe default ----
50
50
 
@@ -628,6 +628,111 @@ test('1002645 reconcileBlockedLines: never reads as success (no "NOT a failure",
628
628
  assert.ok(!/Prod is live/.test(lines), 'nor the success line');
629
629
  });
630
630
 
631
+ // ---- task 1002572 (PART 2): the single-checkout land fallback ----
632
+ // A builder with ONE checkout can never complete a local merge — the tree that
633
+ // would RECEIVE the merge is the tree holding the feature branch. That used to
634
+ // park the task at 'confirmed', unpaid under ADR 0120, with nothing landing. The
635
+ // branch itself is landable, so that bail now falls back to the push+PR path.
636
+ // What must NOT happen is a blanket conversion: a merge conflict, a red
637
+ // post-merge smoke or a failed push are real failures, and a PR would carry the
638
+ // same breakage to main. The decision is therefore opt-in PER BAIL, and these
639
+ // tests pin both halves of it.
640
+
641
+ test('1002572 landBailed: carries the bail id, and defaults to none', () => {
642
+ assert.equal(landBailed('r', 'n', LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH).bailId, LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH);
643
+ // Fail-safe: every pre-existing bail site passes no id, so it stays terminal.
644
+ assert.equal(landBailed('r', 'n').bailId, null);
645
+ // Still a bail — the fallback must not have turned it into a success.
646
+ assert.equal(landBailed('r', 'n', LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH).ok, false);
647
+ assert.equal(landBailed('r', 'n', LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH).pending, false);
648
+ });
649
+
650
+ test('1002572 landBailRecoverableByPr: only the main-checkout-holds-the-branch bail falls back', () => {
651
+ assert.equal(landBailRecoverableByPr(LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH), true);
652
+ // On main there is no head branch, so a PR has nothing to open FROM.
653
+ assert.equal(landBailRecoverableByPr(LAND_BAIL_NO_TASK_BRANCH), false);
654
+ });
655
+
656
+ test('1002572 landBailRecoverableByPr: an untagged bail is terminal (no blanket conversion)', () => {
657
+ // The real failures — conflict, red smoke, failed push, unreachable origin —
658
+ // all reach the caller as untagged bails. Each must stay loud.
659
+ for (const v of [null, undefined, '', 'merge-conflict', 'post-merge-smoke-failed', 'push-failed', 'fetch-failed']) {
660
+ assert.equal(landBailRecoverableByPr(v), false, `${JSON.stringify(v)} must not be PR-recoverable`);
661
+ }
662
+ });
663
+
664
+ test('1002572 onMainNextStep: names the task branch to re-ship from when it exists', () => {
665
+ const step = onMainNextStep(1002572, true);
666
+ assert.match(step, /task-1002572/, 'names the branch to go back to');
667
+ assert.match(step, /node scripts\/gds\/ship\.js 1002572/, 'and the re-ship command for THIS task');
668
+ // The old advice pointed back at the same dead end; that is the regression.
669
+ assert.notEqual(step, '/merge-mode');
670
+ });
671
+
672
+ test('1002572 onMainNextStep: falls back to /merge-mode rather than inventing a branch', () => {
673
+ assert.equal(onMainNextStep(1002572, false), '/merge-mode');
674
+ });
675
+
676
+ // The ROUTING — the actual deliverable. Driven through autoMerge with the merge
677
+ // chain and the PR land stubbed (a real mergeLocally needs a second checkout and
678
+ // an authed gh), so this proves behaviour rather than matching the source.
679
+ const shipMerge = require('../scripts/gds/ship-merge.js');
680
+
681
+ function driveAutoMerge(mergeOutcome) {
682
+ const calls = [];
683
+ const deps = {
684
+ mergeLocally: async () => mergeOutcome,
685
+ ciLand: async (taskId, branch, summary) => {
686
+ calls.push({ taskId, branch, summary });
687
+ return landPending();
688
+ },
689
+ currentBranch: () => 'task-1002572',
690
+ };
691
+ return shipMerge.autoMerge(1002572, 'a summary', {}, deps).then((outcome) => ({ outcome, calls }));
692
+ }
693
+
694
+ await (async () => {
695
+ const recoverable = await driveAutoMerge(landBailed('main checkout is on a branch', '/merge-mode', LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH));
696
+ test('1002572 routing: the single-checkout bail lands via the PR path instead of stranding', () => {
697
+ assert.equal(recoverable.calls.length, 1, 'ciLand was called exactly once');
698
+ assert.deepEqual(recoverable.calls[0], { taskId: 1002572, branch: 'task-1002572', summary: 'a summary' });
699
+ // Pending, not bailed: the card may honestly say "landing automatically" now.
700
+ assert.equal(recoverable.outcome.pending, true);
701
+ assert.equal(recoverable.outcome.reason, null);
702
+ });
703
+
704
+ const terminal = await driveAutoMerge(landBailed('merging task-1002572 into main failed (conflicts?)', '/merge-mode'));
705
+ test('1002572 routing: a real failure stays loud — no PR is opened for a conflict', () => {
706
+ assert.equal(terminal.calls.length, 0, 'a conflict must NOT be converted into a PR');
707
+ assert.equal(terminal.outcome.pending, false);
708
+ assert.match(terminal.outcome.reason, /conflicts/);
709
+ });
710
+
711
+ const onMain = await driveAutoMerge(landBailed('nothing to merge — this checkout is on main, not a task branch', onMainNextStep(1002572, true), LAND_BAIL_NO_TASK_BRANCH));
712
+ test('1002572 routing: standing on main opens no PR (there is no head branch), but names the cure', () => {
713
+ assert.equal(onMain.calls.length, 0);
714
+ assert.match(onMain.outcome.nextStep, /task-1002572/);
715
+ });
716
+
717
+ const success = await driveAutoMerge(landed());
718
+ test('1002572 routing: a successful local merge is untouched (the worktree path does not regress)', () => {
719
+ assert.equal(success.calls.length, 0, 'a clean local merge must not also open a PR');
720
+ assert.equal(success.outcome.ok, true);
721
+ });
722
+ })();
723
+
724
+ // Only the two documented catch-22 bails may carry an id. Behaviour can't see
725
+ // this — a third tagged bail would silently gain a fallback it was never granted.
726
+ test('1002572 wiring: exactly the two catch-22 bails are tagged', () => {
727
+ const src = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'gds', 'ship-merge.js'), 'utf8');
728
+ // Each id appears exactly twice: the require destructure, and its ONE bail site.
729
+ for (const id of [LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH, LAND_BAIL_NO_TASK_BRANCH]) {
730
+ const name = `LAND_BAIL_${id.toUpperCase().replace(/-/g, '_')}`;
731
+ const hits = src.split(name).length - 1;
732
+ assert.equal(hits, 2, `${name}: imported once + used at exactly one bail site (saw ${hits})`);
733
+ }
734
+ });
735
+
631
736
  console.log('');
632
737
  console.log(`ship_ci_deploy: ${passed} passed, ${failed} failed`);
633
738
  process.exit(failed === 0 ? 0 : 1);