@bridge_gpt/mcp-server 0.2.51 → 0.2.52

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 (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. package/package.json +3 -2
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Executor deny-enforcement preflight (TDD §7 / §11, R8).
3
3
  *
4
- * v2's permission model is "skip-permissions + a deterministic deny-layer": workers
5
- * run `claude -p --dangerously-skip-permissions` with a small stable deny set. But
6
- * whether `permissions.deny` is actually enforced under `--dangerously-skip-permissions`
7
- * is version-specific and must be PROBED, never assumed. This module exports the single
4
+ * v2's permission model since BAPI-1020 is "the `auto` posture + a deterministic
5
+ * deny layer + an always-on argument guard": workers run
6
+ * `claude -p … --permission-mode auto` with a small stable deny set, and
7
+ * `--dangerously-skip-permissions` is the explicit REVERT value rather than the
8
+ * default. Whether `permissions.deny` is actually enforced under a given posture is
9
+ * version-specific and must be PROBED, never assumed — and BOTH postures are probed,
10
+ * for different reasons: `auto` because it is what every worker runs under, and
11
+ * bypass because it stays selectable and must stay guarded. A posture that is not
12
+ * enforced is fatal regardless of which one it is. This module exports the single
8
13
  * reusable predicate the T3a executor's claim loop calls at startup/preflight before
9
14
  * claiming any job: on a failed deny probe with no working fallback it returns
10
15
  * `enforced: false`, and the executor must refuse to claim jobs (a fatal finding).
@@ -19,15 +24,28 @@ import { resolveAgentSpec } from "../agent-registry.js";
19
24
  import { createProbeContext } from "../agent-capabilities/probe-context.js";
20
25
  import { createDefaultAgentCapabilitiesDeps } from "../agent-capabilities/default-deps.js";
21
26
  import { runDenyEnforcementCheck } from "../agent-capabilities/probes.js";
27
+ /**
28
+ * The postures probed when a caller names none (BAPI-1020).
29
+ *
30
+ * `auto` FIRST, deliberately: it is the posture every worker runs under, so when
31
+ * enforcement is broken the run that proves it is the one that matters, and the
32
+ * short-circuit below then skips the second probe entirely rather than spending
33
+ * minutes of real headless Claude runs re-confirming a fatal result.
34
+ */
35
+ export const REQUIRED_DENY_ENFORCEMENT_POSTURES = [
36
+ "auto",
37
+ "skip_permissions",
38
+ ];
22
39
  /** Canonical refuse-to-claim directive included in every fatal/degraded-fatal result. */
23
40
  const REFUSE_TO_CLAIM_WARNING = "The executor claim loop MUST refuse to claim jobs until settings permissions.deny " +
24
41
  "or the PreToolUse fallback enforces the deny layer.";
25
42
  /**
26
- * Run the deny-enforcement preflight and map the shared deny-check outcome onto the
27
- * standard inspection shape. Never throws — unexpected exceptions become a fatal
28
- * `enforced: false` result. Always cleans up the probe context's temp dirs.
43
+ * Run the deny-enforcement preflight for ONE posture and map the shared
44
+ * deny-check outcome onto the standard inspection shape. Never throws —
45
+ * unexpected exceptions become a fatal `enforced: false` result. Always cleans up
46
+ * the probe context's temp dirs.
29
47
  */
30
- export async function runDenyEnforcementPreflight(opts = {}) {
48
+ async function runDenyEnforcementPreflightForPosture(posture, opts) {
31
49
  try {
32
50
  const agent = resolveAgentSpec("claude");
33
51
  if (!agent) {
@@ -47,7 +65,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
47
65
  const { result, layer } = await runDenyEnforcementCheck(ctx, {
48
66
  model: opts.model,
49
67
  timeoutMs: opts.timeoutMs,
50
- permissionPosture: opts.permissionPosture,
68
+ permissionPosture: posture,
51
69
  });
52
70
  if (result.status === "pass" && layer === "settings-deny") {
53
71
  return {
@@ -65,7 +83,8 @@ export async function runDenyEnforcementPreflight(opts = {}) {
65
83
  layer: "pretooluse-hook",
66
84
  degraded: true,
67
85
  warnings: [
68
- "settings permissions.deny was not enforced under --dangerously-skip-permissions; relying on PreToolUse fallback.",
86
+ `settings permissions.deny was not enforced under the ${posture ?? "skip_permissions"} ` +
87
+ "posture; relying on PreToolUse fallback.",
69
88
  ],
70
89
  status: result.status,
71
90
  detail: result.detail,
@@ -95,3 +114,81 @@ export async function runDenyEnforcementPreflight(opts = {}) {
95
114
  };
96
115
  }
97
116
  }
117
+ /**
118
+ * Run the deny-enforcement preflight and return the executor's claim gate.
119
+ *
120
+ * With an explicit `permissionPosture`, probes exactly that posture and returns
121
+ * its result unchanged — the single-posture behavior every existing caller and
122
+ * test seam relies on.
123
+ *
124
+ * With no posture named, probes {@link REQUIRED_DENY_ENFORCEMENT_POSTURES} in
125
+ * order and combines (BAPI-1020). Three properties of that combination matter:
126
+ *
127
+ * - FATAL IF EITHER FAILS. `enforced` is the conjunction, so a posture that is
128
+ * not enforced blocks claiming even when the other one is. There is no
129
+ * "mostly enforced".
130
+ * - SHORT-CIRCUITS ON THE FIRST FAILURE. Each posture costs several real
131
+ * headless Claude runs and takes minutes; once the answer is fatal, the
132
+ * remaining postures cannot change it, and burning the time to re-confirm it
133
+ * would delay every claim on a machine that is already refusing to claim.
134
+ * - REPORTS EACH POSTURE SEPARATELY. `postures` carries every probed posture's
135
+ * own layer and detail. The top-level `layer` is the FIRST required posture's
136
+ * (`auto`, the one workers run under) rather than a merged string, so the
137
+ * existing consumers — which read `layer` as "what enforced for a worker" —
138
+ * keep reading a true answer, and the per-posture facts stay available beside
139
+ * it instead of being collapsed into it.
140
+ */
141
+ export async function runDenyEnforcementPreflight(opts = {}) {
142
+ if (opts.permissionPosture !== undefined) {
143
+ return runDenyEnforcementPreflightForPosture(opts.permissionPosture, opts);
144
+ }
145
+ const postures = [];
146
+ const warnings = [];
147
+ let combined;
148
+ let degraded = false;
149
+ for (const posture of REQUIRED_DENY_ENFORCEMENT_POSTURES) {
150
+ const result = await runDenyEnforcementPreflightForPosture(posture, opts);
151
+ postures.push({
152
+ posture,
153
+ enforced: result.enforced,
154
+ layer: result.layer,
155
+ ...(result.status === undefined ? {} : { status: result.status }),
156
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
157
+ });
158
+ for (const warning of result.warnings) {
159
+ if (!warnings.includes(warning))
160
+ warnings.push(warning);
161
+ }
162
+ if (result.degraded)
163
+ degraded = true;
164
+ if (combined === undefined)
165
+ combined = result;
166
+ if (!result.enforced) {
167
+ // Fatal already. Naming the posture is the diagnostic that matters: "deny is
168
+ // unenforced" is not actionable until an operator knows WHICH posture, since
169
+ // the fix for the worker default and the fix for the revert value differ.
170
+ return {
171
+ enforced: false,
172
+ layer: result.layer,
173
+ degraded: true,
174
+ warnings: [
175
+ `Deny-layer enforcement is not verified for the '${posture}' permission posture.`,
176
+ ...warnings,
177
+ ],
178
+ ...(result.status === undefined ? {} : { status: result.status }),
179
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
180
+ postures,
181
+ };
182
+ }
183
+ }
184
+ const primary = combined;
185
+ return {
186
+ enforced: true,
187
+ layer: primary.layer,
188
+ degraded,
189
+ warnings,
190
+ ...(primary.status === undefined ? {} : { status: primary.status }),
191
+ ...(primary.detail === undefined ? {} : { detail: primary.detail }),
192
+ postures,
193
+ };
194
+ }
@@ -35,20 +35,26 @@
35
35
  import { spawn } from "child_process";
36
36
  import { pollCiChecksForCommit, ConductorBridgeApiError, safeDiagnosticMessage, } from "./bridge-api-client.js";
37
37
  import { isLikelyGhMergeConflictOutput, isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
38
- import { selectLatestChecksByName } from "./latest-check-selector.js";
38
+ import { normalizeSelectorCheckName, selectLatestChecksByName } from "./latest-check-selector.js";
39
39
  const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
40
40
  /**
41
41
  * `gh pr view` field set for a merged-state / head-drift read. Includes
42
42
  * `mergeCommit` so an already-merged PR can be detected (and its merge commit
43
43
  * SHA recorded) directly from the same read that guards head drift.
44
+ *
45
+ * BAPI-1021: also includes `baseRefName`/`headRefName` so the ref-verification
46
+ * guard (AC-11) can compare the PR's CURRENT base/head branches against the
47
+ * caller's expectations from the same single read — no extra `gh pr view`
48
+ * subprocess is added solely for ref verification.
44
49
  */
45
- const PR_STATE_JSON = "headRefOid,state,mergeCommit";
50
+ const PR_STATE_JSON = "headRefOid,state,mergeCommit,baseRefName,headRefName";
46
51
  /**
47
52
  * Broader `gh pr view` field set used only for the post-merge-failure re-read:
48
53
  * it must detect BOTH "already merged at the expected head" and a merge conflict
49
- * (`mergeable` / `mergeStateStatus`) from a single read.
54
+ * (`mergeable` / `mergeStateStatus`) from a single read. BAPI-1021: also carries
55
+ * `baseRefName`/`headRefName` for the same ref-verification reason as above.
50
56
  */
51
- const PR_STATE_MERGEABILITY_JSON = "headRefOid,state,mergeCommit,mergeable,mergeStateStatus";
57
+ const PR_STATE_MERGEABILITY_JSON = "headRefOid,state,mergeCommit,mergeable,mergeStateStatus,baseRefName,headRefName";
52
58
  /**
53
59
  * Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
54
60
  * stateless process — a `gh` call that hangs (network stall, an auth prompt that
@@ -166,12 +172,50 @@ function buildResponse(request, status, reason, terminal, ledgerEvents) {
166
172
  ledger_events: ledgerEvents,
167
173
  };
168
174
  }
175
+ /**
176
+ * BAPI-1001 — check-run conclusions GitHub itself treats as NEUTRAL. A required
177
+ * check concluding `skipped` or `neutral` does not hold a pull request back on
178
+ * GitHub, which reports it CLEAN/MERGEABLE. Deliberately NOT a catch-all for
179
+ * "not success": `failure`, `cancelled`, `timed_out`, `action_required`, every
180
+ * pending/queued state, and every unrecognized conclusion stay non-green.
181
+ *
182
+ * Kept LOCAL to merge admission rather than pushed into the shared selector: the
183
+ * selector's other consumers render CI state for humans, where a skipped check
184
+ * is worth showing as skipped rather than silently folded into "pass".
185
+ */
186
+ const NEUTRAL_CHECK_CONCLUSIONS = new Set(["skipped", "neutral"]);
187
+ /** True when a check record's `conclusion` is one GitHub treats as neutral. */
188
+ function isNeutralCheckRecord(record) {
189
+ if (!record)
190
+ return false;
191
+ const conclusion = typeof record.conclusion === "string" ? record.conclusion.trim().toLowerCase() : "";
192
+ return NEUTRAL_CHECK_CONCLUSIONS.has(conclusion);
193
+ }
169
194
  /**
170
195
  * Decide whether every required CI check is green for the polled head SHA. When
171
196
  * the gate lists no required checks, fall back to the poll's `all_passed` flag.
172
197
  * Defensive against the poll response's exact shape: a check counts as green if
173
198
  * any of `conclusion==="success"`, `status==="success"`, `green===true`, or
174
199
  * `bucket==="pass"`.
200
+ *
201
+ * BAPI-1001 adds neutral arbitration AHEAD of the BAPI-933 latest-wins selection.
202
+ * Four outcomes, and each is a distinct case:
203
+ *
204
+ * - `success` (by any of the four spellings above) — green, unchanged.
205
+ * - `skipped` / `neutral` — non-blocking. It never becomes the selected record
206
+ * for a name that also carries a meaningful observation, and when it is the
207
+ * ONLY observation for a required name it passes, because GitHub admits it.
208
+ * - a meaningful failure (`failure`, `cancelled`, `timed_out`,
209
+ * `action_required`, an unorderable conflict) — non-green, unchanged. A
210
+ * neutral duplicate can never hide one, because neutral records are the ones
211
+ * dropped, never the meaningful ones.
212
+ * - pending, queued, missing, malformed — non-green, unchanged.
213
+ *
214
+ * The defect this closes: BAPI-996's PR #1177 carried two `claude-review`
215
+ * check-runs at the head, one `success` and one NEWER `skipped`. Latest-wins
216
+ * selected the `skipped` record, `isGreen` read it as not-green, and the bounded
217
+ * CI-green wait refused a CLEAN/MERGEABLE pull request with `ci_not_green` until
218
+ * the merge re-fire budget parked the ticket.
175
219
  */
176
220
  export function allRequiredChecksGreen(pollResponse, requiredChecks) {
177
221
  if (pollResponse === null || typeof pollResponse !== "object")
@@ -198,14 +242,43 @@ export function allRequiredChecksGreen(pollResponse, requiredChecks) {
198
242
  // reported MERGEABLE/CLEAN (BAPI-912 / PR #1107, three parked attempts).
199
243
  // `selectLatestChecksByName` also OMITS a name whose duplicates could not be
200
244
  // ordered, so the `every(...)` below fails it closed as a missing context.
201
- const { byName } = selectLatestChecksByName(rawChecks);
245
+ // Neutral arbitration runs BEFORE selection, because recency cannot arbitrate
246
+ // between "a verdict" and "no verdict". A neutral duplicate is withheld from
247
+ // the selector whenever the same name also carries a meaningful observation,
248
+ // so latest-wins then arbitrates only among records that actually decided
249
+ // something. Names with nothing but neutral records keep them and are judged
250
+ // by `isGreen` below; every meaningful record is passed through untouched, so
251
+ // the selector's ambiguity/fail-closed handling is unchanged.
252
+ const meaningfulNames = new Set();
253
+ for (const record of rawChecks) {
254
+ if (record === null || typeof record !== "object" || Array.isArray(record))
255
+ continue;
256
+ const name = normalizeSelectorCheckName(record.name);
257
+ if (name !== null && !isNeutralCheckRecord(record))
258
+ meaningfulNames.add(name);
259
+ }
260
+ const arbitrated = rawChecks.filter((record) => {
261
+ if (record === null || typeof record !== "object" || Array.isArray(record))
262
+ return true;
263
+ const name = normalizeSelectorCheckName(record.name);
264
+ if (name === null)
265
+ return true;
266
+ return !(isNeutralCheckRecord(record) && meaningfulNames.has(name));
267
+ });
268
+ const { byName } = selectLatestChecksByName(arbitrated);
202
269
  const isGreen = (c) => {
203
270
  if (!c)
204
271
  return false;
205
272
  const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
206
273
  const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
207
274
  const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
208
- return c.green === true || conclusion === "success" || status === "success" || bucket === "pass";
275
+ return (c.green === true ||
276
+ conclusion === "success" ||
277
+ status === "success" ||
278
+ bucket === "pass" ||
279
+ // Only reachable when the name carried no meaningful observation at all;
280
+ // otherwise the arbitration above already removed this record.
281
+ isNeutralCheckRecord(c));
209
282
  };
210
283
  return requiredChecks.every((name) => isGreen(byName.get(name)));
211
284
  }
@@ -229,11 +302,26 @@ function isMergedAtExpectedHead(state, headOid, expectedSha) {
229
302
  headOid.toLowerCase() === expectedSha.toLowerCase());
230
303
  }
231
304
  /**
232
- * Read the PR's `headRefOid` + `state` + `mergeCommit` via `gh pr view`. Extracted
233
- * so the head-drift / not-open / already-merged guard can run BOTH before the CI
234
- * wait and again right before provider merge (BAPI-566 Bug B: the head can drift
235
- * while we wait for CI). Returns the raw parsed values; the caller applies the
236
- * MERGED-at-head / OPEN / exact-head checks.
305
+ * Coerce a parsed `gh pr view` field to a bounded, nonblank ref name, or
306
+ * `undefined` when the field is absent, not a string, or whitespace-only.
307
+ * BAPI-1021: an unusable observed ref must be treated by the ref-verification
308
+ * guard as a MISMATCH when the caller supplied an expectation, never silently
309
+ * skipped this coercion just normalizes the raw value, it does not decide
310
+ * that policy.
311
+ */
312
+ function readBoundedRefName(value) {
313
+ if (typeof value !== "string")
314
+ return undefined;
315
+ const trimmed = value.trim();
316
+ return trimmed.length > 0 ? trimmed : undefined;
317
+ }
318
+ /**
319
+ * Read the PR's `headRefOid` + `state` + `mergeCommit` + `baseRefName` +
320
+ * `headRefName` via `gh pr view`. Extracted so the head-drift / not-open /
321
+ * already-merged / ref-mismatch guard can run BOTH before the CI wait and
322
+ * again right before provider merge (BAPI-566 Bug B: the head can drift while
323
+ * we wait for CI). Returns the raw parsed values; the caller applies the
324
+ * MERGED-at-head / OPEN / exact-head / matching-ref checks.
237
325
  */
238
326
  async function readPrMergeState(run, ghEnv, pr, json = PR_STATE_JSON) {
239
327
  const view = await run("gh", ["pr", "view", String(pr), "--json", json], ghEnv);
@@ -248,6 +336,8 @@ async function readPrMergeState(run, ghEnv, pr, json = PR_STATE_JSON) {
248
336
  headOid: parsed.headRefOid,
249
337
  state: parsed.state,
250
338
  mergeCommitOid: extractMergeCommitOid(parsed),
339
+ baseRefName: readBoundedRefName(parsed.baseRefName),
340
+ headRefName: readBoundedRefName(parsed.headRefName),
251
341
  raw: parsed,
252
342
  };
253
343
  }
@@ -381,6 +471,8 @@ async function waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredC
381
471
  */
382
472
  export function makeLocalMergeExecutor(options = {}, deps = {}) {
383
473
  const method = resolveLocalMergeMethod(options.method);
474
+ const expectedBaseBranch = readBoundedRefName(options.expectedBaseBranch);
475
+ const expectedHeadBranch = readBoundedRefName(options.expectedHeadBranch);
384
476
  const rawRun = deps.runCommand ?? defaultRunCommand;
385
477
  // Always await the runner: the production default is async (a hung `gh` would
386
478
  // otherwise block the event loop and starve the executor heartbeat), and
@@ -479,6 +571,15 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
479
571
  if (isMergedAtExpectedHead(firstRead.state, firstRead.headOid, expectedSha)) {
480
572
  return buildAlreadyMergedResponse(request, baseDetails, firstRead.mergeCommitOid);
481
573
  }
574
+ // 2b. BAPI-1021 (AC-11) — refuse before any provider merge if the PR's current
575
+ // base or head branch does not match the caller's expectation. Ordered
576
+ // after the already-merged short-circuit (an idempotent success must not
577
+ // be turned into a refusal by a since-changed ref) and before the
578
+ // head-drift / pr_not_open guards below.
579
+ const firstRefMismatch = findRefMismatch(expectedBaseBranch, expectedHeadBranch, firstRead);
580
+ if (firstRefMismatch) {
581
+ return buildRefMismatchResponse(request, baseDetails, firstRefMismatch);
582
+ }
482
583
  if (typeof firstRead.headOid !== "string" || firstRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
483
584
  return fail("head_drift");
484
585
  }
@@ -517,6 +618,13 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
517
618
  if (isMergedAtExpectedHead(secondRead.state, secondRead.headOid, expectedSha)) {
518
619
  return buildAlreadyMergedResponse(request, baseDetails, secondRead.mergeCommitOid);
519
620
  }
621
+ // 3c. BAPI-1021 (AC-11) — repeat the ordered ref guard here too, so a PR
622
+ // retargeted while CI was pending cannot reach the merge command. Same
623
+ // ordering rationale as the first-read guard above.
624
+ const secondRefMismatch = findRefMismatch(expectedBaseBranch, expectedHeadBranch, secondRead);
625
+ if (secondRefMismatch) {
626
+ return buildRefMismatchResponse(request, baseDetails, secondRefMismatch);
627
+ }
520
628
  if (typeof secondRead.headOid !== "string" || secondRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
521
629
  return fail("head_drift");
522
630
  }
@@ -625,6 +733,57 @@ function buildConflictResponse(request, baseDetails, expectedSha, mergeability)
625
733
  { type: "merge.conflict", status: "failed", reason, details: conflictDetails },
626
734
  ]);
627
735
  }
736
+ /**
737
+ * Compare the PR's CURRENT base/head refs (from a {@link readPrMergeState}
738
+ * read) against the caller's supplied expectations. Returns the first
739
+ * mismatch found (base checked before head), or `undefined` when every
740
+ * supplied expectation matches.
741
+ *
742
+ * An expectation with no corresponding usable actual ref — absent, not a
743
+ * string, or whitespace-only, already normalized to `undefined` by
744
+ * {@link readBoundedRefName} — counts as a MISMATCH, never a skip. Silently
745
+ * treating an unreadable ref as "nothing to check" would let exactly the
746
+ * unverifiable case slip through the guard it exists to provide.
747
+ */
748
+ function findRefMismatch(expectedBaseBranch, expectedHeadBranch, actual) {
749
+ if (expectedBaseBranch !== undefined && actual.baseRefName !== expectedBaseBranch) {
750
+ return { field: "base", expected: expectedBaseBranch, actual: actual.baseRefName ?? "" };
751
+ }
752
+ if (expectedHeadBranch !== undefined && actual.headRefName !== expectedHeadBranch) {
753
+ return { field: "head", expected: expectedHeadBranch, actual: actual.headRefName ?? "" };
754
+ }
755
+ return undefined;
756
+ }
757
+ /**
758
+ * Build a `ref_mismatch` failure (AC-11): a deliberate, terminal safety
759
+ * refusal issued BEFORE any provider-merge subprocess when the PR's current
760
+ * base or head branch does not match the caller's expectation.
761
+ *
762
+ * Details are allowlisted to the expected/actual branch values only — never
763
+ * raw `gh` output, stderr, response bodies, credentials, or exception text,
764
+ * mirroring {@link buildConflictResponse}'s secret-safety contract. The
765
+ * message states plainly that no provider merge was attempted and directs the
766
+ * operator to rebuild or cherry-pick the change against the expected run base
767
+ * — retargeting the pull request in the GitHub UI would only relabel a branch
768
+ * that has already drifted from what the run actually intends to merge, not
769
+ * fix it.
770
+ */
771
+ function buildRefMismatchResponse(request, baseDetails, mismatch) {
772
+ const reason = "ref_mismatch";
773
+ const details = {
774
+ ...baseDetails,
775
+ mismatched_field: mismatch.field,
776
+ [`expected_${mismatch.field}_branch`]: mismatch.expected,
777
+ [`actual_${mismatch.field}_branch`]: mismatch.actual,
778
+ message: `No provider merge was attempted: the pull request's current ${mismatch.field} branch ` +
779
+ `does not match the run's expected ${mismatch.field} branch. Rebuild or cherry-pick the ` +
780
+ "change against the expected run base rather than retargeting the pull request in the " +
781
+ "GitHub UI.",
782
+ };
783
+ return buildResponse(request, "failed", reason, false, [
784
+ { type: "merge.failed", status: "failed", reason, details },
785
+ ]);
786
+ }
628
787
  /**
629
788
  * Probe whether this host can merge with `gh` at all, BEFORE any merge is
630
789
  * attempted.