@bridge_gpt/mcp-server 0.2.20 → 0.2.23

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Per-job base-branch resolution (BAPI-586).
3
+ *
4
+ * The executor has one global `--base-branch` (`ExecutorOptions.baseBranch`,
5
+ * default `main`), but Epic Conductor runs can be based on a custom branch
6
+ * (e.g. `develop`, `release/2026.07`) and multi-repository executors may serve
7
+ * repos with different bases. This helper makes the persisted RUN base
8
+ * (`job.payload.base_branch`) authoritative for each job while keeping the CLI
9
+ * value as a legacy fallback.
10
+ *
11
+ * A malformed present value fails CLOSED (contract error) rather than silently
12
+ * falling back — a wrong base is exactly the BAPI-586 defect. An OMITTED value
13
+ * (legacy / older-Conductor jobs) degrades gracefully to the CLI base.
14
+ */
15
+ import { validateBranchName } from "../base-ref.js";
16
+ /**
17
+ * Resolve the effective logical base branch for one executor job.
18
+ *
19
+ * - `payload.base_branch` ABSENT (`undefined`) → legacy job → `fallbackBaseBranch`.
20
+ * - present and a valid branch name → authoritative for this job, even when it
21
+ * differs from the executor-wide `--base-branch`.
22
+ * - present but malformed (`null`, non-string, empty/whitespace, `..`, `.lock`,
23
+ * control chars, leading `-`) → contract failure. The diagnostic names the
24
+ * base-branch contract WITHOUT echoing arbitrary payload contents.
25
+ */
26
+ export function resolveExecutorJobBaseBranch(job, fallbackBaseBranch) {
27
+ const payload = job.payload;
28
+ const raw = payload && typeof payload === "object"
29
+ ? payload.base_branch
30
+ : undefined;
31
+ // Omitted field: legacy / older-Conductor job → CLI fallback (backward compat).
32
+ if (raw === undefined) {
33
+ return { ok: true, baseBranch: fallbackBaseBranch };
34
+ }
35
+ // Present but not a string (null, number, object, …): fail closed, no echo.
36
+ if (typeof raw !== "string") {
37
+ return {
38
+ ok: false,
39
+ error: "job payload base_branch is present but is not a string branch name.",
40
+ };
41
+ }
42
+ const validationError = validateBranchName(raw);
43
+ if (validationError) {
44
+ return {
45
+ ok: false,
46
+ error: `job payload base_branch is not a valid branch name: ${validationError}`,
47
+ };
48
+ }
49
+ return { ok: true, baseBranch: raw };
50
+ }
@@ -8,6 +8,7 @@
8
8
  * keys — it never copies arbitrary `process.env`, so credentials/tokens/headers
9
9
  * cannot leak into the spawned `claude` process.
10
10
  */
11
+ import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
11
12
  /** Non-secret operational keys forwarded to the worker when present. */
12
13
  const ALLOWED_ENV_KEYS = [
13
14
  "PATH",
@@ -63,8 +64,15 @@ export function isExecutorEnvKeyAllowed(key) {
63
64
  * anyway, and the delay was observed causing a worker to background the push and
64
65
  * exit before it landed. `BAPI_CONDUCTOR_*` identity keys remain intentionally
65
66
  * absent from the worker env (TDD §7) — this literal does not change that.
67
+ *
68
+ * BAPI-586: when `effectiveBaseBranch` is provided (the validated run base for
69
+ * this job), it is injected as `BAPI_BASE_BRANCH` so the worker can run
70
+ * `gh pr create --base "$BAPI_BASE_BRANCH"` deterministically. It is set from the
71
+ * EXPLICIT job value, NOT copied from `parentEnv` (`BAPI_BASE_BRANCH` is not in
72
+ * the allowlist), so any inherited value cannot override the run base and no
73
+ * unrelated `BAPI_*` / secret value can leak in via this key.
66
74
  */
67
- export function buildExecutorWorkerEnv(parentEnv) {
75
+ export function buildExecutorWorkerEnv(parentEnv, effectiveBaseBranch) {
68
76
  const env = {};
69
77
  for (const key of ALLOWED_ENV_KEYS) {
70
78
  if (!isExecutorEnvKeyAllowed(key))
@@ -75,5 +83,8 @@ export function buildExecutorWorkerEnv(parentEnv) {
75
83
  }
76
84
  }
77
85
  env.BRIDGE_SKIP_PREPUSH = "1";
86
+ if (typeof effectiveBaseBranch === "string" && effectiveBaseBranch.length > 0) {
87
+ env[PR_BASE_BRANCH_ENV_VAR] = effectiveBaseBranch;
88
+ }
78
89
  return env;
79
90
  }
@@ -3,6 +3,7 @@ export const MissingVerdictArtifact = "MissingVerdictArtifact";
3
3
  export const WorktreeLostBeforePush = "WorktreeLostBeforePush";
4
4
  export const BranchMismatch = "BranchMismatch";
5
5
  export const WorkerFinalizationMissingRemoteBranchAndPr = "WorkerFinalizationMissingRemoteBranchAndPr";
6
+ export const WorkerFinalizationPrBaseMismatch = "WorkerFinalizationPrBaseMismatch";
6
7
  /** Bound a failure message so no unbounded/secret-bearing text is posted. */
7
8
  const ERROR_MESSAGE_MAX_CHARS = 300;
8
9
  /**
@@ -30,9 +30,11 @@ import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
30
30
  import { createWorkerLogTee, closeWorkerLogTee, teeAsyncIterable, } from "./worker-log.js";
31
31
  import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
32
32
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
33
- import { isRecoveryJobType, isSpawnJobType } from "./job-types.js";
33
+ import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
34
+ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
34
35
  import { validateWorkerFinalization } from "./worker-finalization.js";
35
36
  import { ensureExecutorWorktree } from "./worktree.js";
37
+ import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
36
38
  import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
37
39
  import { collectGitTelemetry } from "./observation.js";
38
40
  /** Default runtime for the no-op smoke process (ms). */
@@ -553,11 +555,37 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
553
555
  }
554
556
  }
555
557
  async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
558
+ // --- Per-job base branch (BAPI-586) ----------------------------------
559
+ // Resolve the effective logical base BEFORE any side effect: the persisted
560
+ // run base (`payload.base_branch`) is authoritative, falling back to the
561
+ // executor CLI base for legacy jobs. A present-but-malformed value fails
562
+ // CLOSED with `ContractError.BaseBranch` and never spawns. The resulting
563
+ // job-scoped options carry that base through worktree seeding, prompt
564
+ // rendering, deny layer, telemetry, env, and finalization so they cannot
565
+ // disagree about the base.
566
+ const baseResolution = resolveExecutorJobBaseBranch(job, options.baseBranch);
567
+ if (!baseResolution.ok) {
568
+ await httpClient.fail(job, {
569
+ error_kind: "ContractError.BaseBranch",
570
+ error_message: baseResolution.error,
571
+ classification: "crashed",
572
+ });
573
+ return { status: "failed", reason: "base_branch_contract" };
574
+ }
575
+ const effectiveBaseBranch = baseResolution.baseBranch;
576
+ const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
556
577
  // --- Worktree + prompt (resume pre-spawn protocol or standard) -------
557
- const prep = await prepareSpawn(job, httpClient, options, deps, seams);
578
+ const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
558
579
  if (!prep.ok)
559
580
  return prep.result;
560
- const { worktreePath, branch, prompt } = prep;
581
+ const { worktreePath, branch } = prep;
582
+ // BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
583
+ // are told to open the PR against the injected run base. Verdict-only
584
+ // `spec_review` produces no PR, so it is excluded from the instruction while
585
+ // still using the pinned base for its fresh worktree.
586
+ const prompt = isImplementationStyleJobType(job.job_type)
587
+ ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
588
+ : prep.prompt;
561
589
  // --- Timeout contract ------------------------------------------------
562
590
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
563
591
  if (!timeout.ok) {
@@ -569,7 +597,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
569
597
  return { status: "failed", reason: "timeout_contract" };
570
598
  }
571
599
  // --- Deny layer (fail-open) ------------------------------------------
572
- const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: options.baseBranch }, {
600
+ const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
573
601
  readFile: deps.readFile,
574
602
  writeFile: deps.writeFile,
575
603
  mkdir: deps.mkdir,
@@ -643,7 +671,9 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
643
671
  // --- Spawn -----------------------------------------------------------
644
672
  const alias = resolveExecutorModelAlias(job.payload);
645
673
  const argv = buildClaudeExecutorArgv(prompt, alias);
646
- const env = buildExecutorWorkerEnv(deps.env);
674
+ // BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
675
+ // can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
676
+ const env = buildExecutorWorkerEnv(deps.env, effectiveBaseBranch);
647
677
  let proc;
648
678
  try {
649
679
  proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
@@ -666,11 +696,11 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
666
696
  stderr: proc.stderr ? teeAsyncIterable(proc.stderr, tee, "stderr") : null,
667
697
  }
668
698
  : proc;
669
- const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, options.baseBranch);
699
+ const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
670
700
  const procResult = await superviseProcess({
671
701
  job,
672
702
  httpClient,
673
- options,
703
+ options: jobOptions,
674
704
  deps,
675
705
  ownership,
676
706
  observation,
@@ -765,6 +795,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
765
795
  result,
766
796
  runCommand: deps.runCommand,
767
797
  headSha: git.last_commit_sha,
798
+ expectedBaseBranch: effectiveBaseBranch,
768
799
  });
769
800
  if (!finalization.ok) {
770
801
  await finalizeRegistry();
@@ -28,7 +28,12 @@ export class VirtualClock {
28
28
  }
29
29
  }
30
30
  async function flushMicrotasks() {
31
- for (let i = 0; i < 12; i++)
31
+ // BAPI-586: the fresh executor worktree path now awaits a `git fetch` +
32
+ // `git rev-parse` (base pin) and a post-create head verification BEFORE the
33
+ // worker spawn, adding several microtask turns. Drain generously so a
34
+ // `tickUntil` flush still reaches the spawn/exit in one pass (draining an
35
+ // already-empty queue is a harmless no-op).
36
+ for (let i = 0; i < 64; i++)
32
37
  await Promise.resolve();
33
38
  }
34
39
  /** A controllable fake owned process for `implement` spawn tests. */
@@ -23,7 +23,7 @@
23
23
  * branch tip SHA against the worker's own HEAD commit (`headSha`, from git
24
24
  * telemetry) whenever that comparison is available.
25
25
  */
26
- import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
26
+ import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, } from "./job-errors.js";
27
27
  import { isImplementationStyleJobType } from "./job-types.js";
28
28
  /** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
29
29
  const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
@@ -111,6 +111,66 @@ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, b
111
111
  }
112
112
  return remoteSha;
113
113
  }
114
+ /**
115
+ * BAPI-586: resolve the base branch of the PR for `branch` via `gh pr view`.
116
+ * Fail-open on any unavailability (gh missing, non-zero exit, unparseable JSON)
117
+ * → `{ found: false }`, so a wrong-base guard never blocks a job on transport
118
+ * failure; the reconciliation observation (epic-runtime) is the second layer.
119
+ * When a PR IS visible, `baseRef` is the trimmed `baseRefName`, or null if that
120
+ * field is absent/non-string (which the caller treats as a fail-closed mismatch —
121
+ * missing base evidence is never a match). Never copies raw stderr.
122
+ */
123
+ async function resolvePrBaseRef(runCommand, worktreePath, branch) {
124
+ const args = ["pr", "view"];
125
+ if (branch)
126
+ args.push(branch);
127
+ args.push("--json", "number,baseRefName");
128
+ let result;
129
+ try {
130
+ result = await runCommand("gh", args, { cwd: worktreePath });
131
+ }
132
+ catch {
133
+ return { found: false, baseRef: null };
134
+ }
135
+ if (result.exitCode !== 0)
136
+ return { found: false, baseRef: null };
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(result.stdout);
140
+ }
141
+ catch {
142
+ return { found: false, baseRef: null };
143
+ }
144
+ if (!parsed || typeof parsed !== "object")
145
+ return { found: false, baseRef: null };
146
+ const obj = parsed;
147
+ const prNumber = typeof obj.number === "number" ? obj.number : undefined;
148
+ const rawBase = obj.baseRefName;
149
+ const baseRef = typeof rawBase === "string" && rawBase.trim().length > 0 ? rawBase.trim() : null;
150
+ return { found: true, prNumber, baseRef };
151
+ }
152
+ /**
153
+ * BAPI-586: bounded, secret-free failure for a PR that targets a branch other
154
+ * than the run base. Details are limited to ticket key, PR number, expected base,
155
+ * and actual base (all public identifiers). The operator guidance names cherry-pick
156
+ * rebuild as the remedy and explicitly warns against a GitHub-UI retarget (a branch
157
+ * built on a squash-merged dependency goes CONFLICTING when retargeted). This guard
158
+ * is OBSERVATIONAL: it performs no `gh pr edit`, reset, force-push, or branch
159
+ * reconstruction.
160
+ */
161
+ function prBaseMismatchFailure(job, prNumber, expectedBase, actualBase) {
162
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
163
+ const prLabel = typeof prNumber === "number" ? `PR #${prNumber}` : "its PR";
164
+ return {
165
+ error_kind: WorkerFinalizationPrBaseMismatch,
166
+ error_message: `${label} opened ${prLabel} against base '${actualBase}', but the run base is '${expectedBase}'. ` +
167
+ `A PR that does not target '${expectedBase}' never triggers code review and would strand the ticket. ` +
168
+ `Recovery: rebuild the feature branch from a fresh origin/${expectedBase} and cherry-pick only this ` +
169
+ `ticket's own commits, then force-push. Do NOT merely retarget the PR base in the GitHub UI — a branch ` +
170
+ `built on a squash-merged dependency goes CONFLICTING when retargeted.`,
171
+ classification: "crashed",
172
+ };
173
+ }
114
174
  function missingBranchAndPrFailure(job, detail) {
115
175
  const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
116
176
  return {
@@ -129,6 +189,33 @@ export async function validateWorkerFinalization(input) {
129
189
  if (!isImplementationStyleJobType(job.job_type)) {
130
190
  return { ok: true };
131
191
  }
192
+ // BAPI-586: a wrong-base PR (opened against a dependency's feature branch or the
193
+ // repo default instead of the run base) never fires `claude-review.yml` and
194
+ // strands the ticket at `code_review`. When the run base is known, query the
195
+ // PR's `baseRefName` and fail loud on any mismatch BEFORE accepting the clean
196
+ // exit — even when a `pr_url` is present (a PR existing is not proof of a
197
+ // correct base). Fail-open when no PR is visible (gh unavailable / no PR):
198
+ // reconciliation is the backstop. A PR with absent/malformed base data is a
199
+ // fail-CLOSED mismatch — missing base evidence is never treated as a match.
200
+ const expectedBase = typeof input.expectedBaseBranch === "string" ? input.expectedBaseBranch.trim() : "";
201
+ if (expectedBase) {
202
+ const branchForPr = typeof branch === "string" ? branch.trim() : "";
203
+ const prBase = await resolvePrBaseRef(runCommand, worktreePath, branchForPr);
204
+ if (prBase.found) {
205
+ if (prBase.baseRef === null) {
206
+ return {
207
+ ok: false,
208
+ failure: prBaseMismatchFailure(job, prBase.prNumber, expectedBase, "(unresolved)"),
209
+ };
210
+ }
211
+ if (prBase.baseRef !== expectedBase) {
212
+ return {
213
+ ok: false,
214
+ failure: prBaseMismatchFailure(job, prBase.prNumber, expectedBase, prBase.baseRef),
215
+ };
216
+ }
217
+ }
218
+ }
132
219
  if (extractPrUrl(result)) {
133
220
  return { ok: true };
134
221
  }
@@ -6,6 +6,7 @@
6
6
  * construction or Worktrunk JSON path parsing here. Per-job create failures are
7
7
  * returned as structured job failures (→ `/fail`), never thrown.
8
8
  */
9
+ import { fetchAndResolveBaseSha } from "../base-ref.js";
9
10
  import { commandSucceeded } from "../start-tickets-prereqs.js";
10
11
  import { createWorktreeForTicket } from "../worktree-core.js";
11
12
  /**
@@ -95,7 +96,26 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
95
96
  }
96
97
  return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
97
98
  }
98
- const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree);
99
+ // BAPI-586 (fresh dispatch): a fresh implementation-style job must start from
100
+ // the CURRENT remote base — never a stale local `main`, another local base
101
+ // branch, or a sibling/dependency feature branch. Fetch `origin/<base>` and
102
+ // pin its immutable SHA (serialized per repo by the shared helper so
103
+ // concurrent fresh jobs don't collide on git lock files), then:
104
+ // - cut an absent branch directly from that SHA (Worktrunk `-b <sha>`),
105
+ // - align a guard-approved pre-existing branch exactly to that SHA, and
106
+ // - verify the created worktree head equals that SHA before spawning.
107
+ // If the refresh fails, fail CLOSED with a structured, secret-free error —
108
+ // continuing from a cached local base would silently omit a merged dependency
109
+ // and reproduce the BAPI-586 wrong-base strand.
110
+ const resolvedBase = await fetchAndResolveBaseSha(deps, options.baseBranch);
111
+ if (!resolvedBase.ok) {
112
+ return {
113
+ ok: false,
114
+ error: `failed to resolve remote base '${options.baseBranch}' for a fresh worktree: ${resolvedBase.error}`,
115
+ };
116
+ }
117
+ const baseSha = resolvedBase.base_sha;
118
+ const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, { alignExistingBranchTo: baseSha, verifyHeadMatches: baseSha });
99
119
  if (row.status === "created" && typeof row.path === "string") {
100
120
  return { ok: true, worktreePath: row.path, branch };
101
121
  }