@bridge_gpt/mcp-server 0.2.58 → 0.2.60

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 (127) hide show
  1. package/README.md +231 -113
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/agent-registry.js +15 -0
  5. package/build/agent-utils.js +16 -8
  6. package/build/claude-user-config-doctor.js +1 -35
  7. package/build/codex-bridge-profile.js +1 -1
  8. package/build/commands.generated.js +7 -8
  9. package/build/conductor/bridge-api-client.js +202 -2
  10. package/build/conductor/bring-up-facts.js +1 -1
  11. package/build/conductor/claude-hook.js +16 -16
  12. package/build/conductor/doctor.js +279 -72
  13. package/build/conductor/epic-implementer-cli.js +38 -5
  14. package/build/conductor/epic-reconcile.js +1 -1
  15. package/build/conductor/epic-runtime.js +21 -12
  16. package/build/conductor/errors.js +3 -3
  17. package/build/conductor/file-scope-guard.js +4 -4
  18. package/build/conductor/git-ci-types.js +1 -1
  19. package/build/conductor/git-inspection.js +2 -2
  20. package/build/conductor/install-doctor.js +28 -6
  21. package/build/conductor/local-merge.js +319 -26
  22. package/build/conductor/merge-execution-contract.js +61 -0
  23. package/build/conductor/merge-execution-eligibility.js +129 -0
  24. package/build/conductor/merge-ledger.js +2 -2
  25. package/build/conductor/pr-ci-producer.js +6 -6
  26. package/build/conductor/pr-review-producer.js +2 -2
  27. package/build/conductor/producer-ledger.js +2 -0
  28. package/build/conductor/readiness-sections.js +166 -9
  29. package/build/conductor/readiness.js +97 -16
  30. package/build/conductor/recovery-cli.js +243 -12
  31. package/build/conductor/recovery-operations.js +64 -6
  32. package/build/conductor/repair-contract.js +38 -4
  33. package/build/conductor/spec-review-producer.js +6 -6
  34. package/build/conductor/store.js +10 -10
  35. package/build/conductor/supervisor-config.js +14 -14
  36. package/build/conductor/supervisor-ledger.js +2 -2
  37. package/build/conductor/supervisor-message-relay.js +2 -2
  38. package/build/conductor/worker-ledger-cli.js +19 -19
  39. package/build/conductor-bundle-artifacts.js +2 -2
  40. package/build/connect-bitbucket-api.js +6 -1
  41. package/build/connect-bitbucket.js +398 -88
  42. package/build/credential-store.js +16 -0
  43. package/build/docs.generated.js +2 -2
  44. package/build/doctor.js +199 -303
  45. package/build/drive-epic.js +88 -71
  46. package/build/epic-implementer-bin.js +22 -17
  47. package/build/epic-implementer-claude-hook-bin.js +4 -2
  48. package/build/epic-implementer-env.js +184 -0
  49. package/build/epic-integration-pr.js +2 -2
  50. package/build/executor/ci-rerun-job.js +269 -0
  51. package/build/executor/cli.js +31 -0
  52. package/build/executor/credentials.js +11 -0
  53. package/build/executor/env.js +79 -8
  54. package/build/executor/heartbeat.js +1 -1
  55. package/build/executor/index.js +21 -4
  56. package/build/executor/job-errors.js +3 -3
  57. package/build/executor/job-runner.js +127 -1
  58. package/build/executor/job-types.js +12 -2
  59. package/build/executor/merge-job.js +46 -5
  60. package/build/executor/observation.js +1 -1
  61. package/build/executor/preflight.js +1 -1
  62. package/build/executor/prompt-spec.js +1 -1
  63. package/build/executor/reconcile-overlap-job.js +10 -130
  64. package/build/executor/reconcile-overlap-types.js +1 -1
  65. package/build/executor/runner.js +1 -1
  66. package/build/executor/service-lifecycle.js +136 -0
  67. package/build/executor/service-unit.js +427 -9
  68. package/build/executor/types.js +5 -0
  69. package/build/index.js +320 -342
  70. package/build/init.js +20 -28
  71. package/build/install-bridge-conductor.js +208 -15
  72. package/build/install-bridge.js +555 -37
  73. package/build/mcp-host-entry-adapter.js +6 -0
  74. package/build/mcp-host-targets.js +2 -0
  75. package/build/mcp-install-state.js +42 -4
  76. package/build/mcp-invoke.js +5 -5
  77. package/build/mcp-profile.js +2 -2
  78. package/build/mcp-provisioning.js +2 -2
  79. package/build/mcp-server-invocation.js +3 -3
  80. package/build/merge-pull-request.js +118 -10
  81. package/build/merge-workflow-codeowners.js +224 -0
  82. package/build/merge-workflow-config.js +133 -0
  83. package/build/merge-workflow-drift.js +66 -0
  84. package/build/merge-workflow-install.js +638 -0
  85. package/build/merge-workflow.js +572 -0
  86. package/build/model-routing-host.js +25 -0
  87. package/build/model-routing-preferences.js +99 -0
  88. package/build/pipelines.generated.js +10 -10
  89. package/build/{plan-epic-conductor-eligibility.js → plan-epic-implementer-eligibility.js} +18 -16
  90. package/build/plan-execution-ledger.js +8 -0
  91. package/build/plane/cli.js +24 -2
  92. package/build/plane/member-roster.js +5 -5
  93. package/build/plane/preflight.js +68 -0
  94. package/build/plane/supervisor.js +1 -1
  95. package/build/plane/types.js +5 -5
  96. package/build/readiness-check.js +1 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/review-tickets.js +4 -4
  99. package/build/run-unit-tests-launcher.js +5 -0
  100. package/build/setup-epic.js +171 -17
  101. package/build/start-tickets-conductor.js +32 -32
  102. package/build/start-tickets-prereqs.js +4 -9
  103. package/build/start-tickets.js +475 -91
  104. package/build/version.generated.js +3 -3
  105. package/docs/CONDUCTOR.md +69 -18
  106. package/docs/install/mcp-tool-integrations.md +2 -1
  107. package/package.json +4 -4
  108. package/pipelines/implement-ticket.json +1 -1
  109. package/pipelines/plan-epic.json +2 -2
  110. package/build/conduct-epic/bridge-client.js +0 -773
  111. package/build/conduct-epic/checkpoint-store.js +0 -542
  112. package/build/conduct-epic/cli.js +0 -3145
  113. package/build/conduct-epic/cut-protocol.js +0 -392
  114. package/build/conduct-epic/lock.js +0 -302
  115. package/build/conduct-epic/pr-state.js +0 -286
  116. package/build/conduct-epic/spawn.js +0 -113
  117. package/build/conductor/cli.js +0 -1278
  118. package/build/conductor-bundle-cli.js +0 -256
  119. package/build/direct-ticket-tools.js +0 -70
  120. package/build/executor/reconcile-overlap-governance.js +0 -129
  121. package/build/implement-epic/checkpoint-store.js +0 -542
  122. package/build/implement-epic/cli.js +0 -3158
  123. package/build/implement-epic/lock.js +0 -302
  124. package/build/implement-epic/pr-state.js +0 -286
  125. package/build/implement-epic/spawn.js +0 -113
  126. /package/build/{implement-epic → epic-shared}/bridge-client.js +0 -0
  127. /package/build/{implement-epic → epic-shared}/cut-protocol.js +0 -0
@@ -10,6 +10,12 @@ export function adaptBridgeEntryForHostTarget(entry, target) {
10
10
  args: [...entry.args],
11
11
  env: { ...entry.env },
12
12
  };
13
+ // The marker belongs to the installed physical registration, never the
14
+ // portable logical entry or committed .bridge/config. Replacing an entry
15
+ // refreshes it; removing the registration removes it automatically.
16
+ delete physical.env.BAPI_MCP_HOST_FAMILY;
17
+ if (target.routingHostFamily)
18
+ physical.env.BAPI_MCP_HOST_FAMILY = target.routingHostFamily;
13
19
  if (adapter.transportType !== undefined)
14
20
  physical.type = adapter.transportType;
15
21
  if (adapter.extraEntryKeys)
@@ -67,6 +67,7 @@ export function hostAdapterForTarget(target) {
67
67
  export const MCP_HOST_TARGETS = {
68
68
  "claude-code": {
69
69
  id: "claude-code",
70
+ routingHostFamily: "claude_code",
70
71
  label: "Claude Code",
71
72
  scope: "project",
72
73
  relPath: ".mcp.json",
@@ -106,6 +107,7 @@ export const MCP_HOST_TARGETS = {
106
107
  },
107
108
  codex: {
108
109
  id: "codex",
110
+ routingHostFamily: "codex",
109
111
  label: "OpenAI Codex",
110
112
  scope: "global",
111
113
  absPathResolver: (homedir) => joinPath(homedir, ".codex/config.toml"),
@@ -64,12 +64,46 @@ function normalizePlatforms(ids) {
64
64
  const wanted = new Set(ids);
65
65
  return HOST_PLATFORM_ORDER.filter((id) => wanted.has(id));
66
66
  }
67
- /** Dedupe + stably order relative project config paths. */
67
+ /**
68
+ * Is this a plain, repository-relative POSIX path safe to record?
69
+ *
70
+ * BAPI-1136. The install manifest is read back by `doctor` and by the installer
71
+ * itself to decide which files Bridge manages. A recorded absolute path, a `..`
72
+ * escape, or a Windows separator therefore names a file OUTSIDE the project that
73
+ * a later tool would treat as project-owned. The install-state file's own path is
74
+ * excluded for the same reason — recording it would make the manifest claim
75
+ * itself as a managed artifact.
76
+ */
77
+ export function isRecordableProjectPath(relPath) {
78
+ if (typeof relPath !== "string" || relPath.length === 0)
79
+ return false;
80
+ if (relPath.includes("\\"))
81
+ return false;
82
+ if (relPath.startsWith("/"))
83
+ return false;
84
+ if (/^[A-Za-z]:/.test(relPath))
85
+ return false;
86
+ if (relPath === MCP_INSTALL_STATE_RELPATH)
87
+ return false;
88
+ const segments = relPath.split("/");
89
+ if (segments.some((seg) => seg.length === 0 || seg === "." || seg === ".."))
90
+ return false;
91
+ return true;
92
+ }
93
+ /**
94
+ * Dedupe + stably order relative project config paths, DROPPING any path that is
95
+ * not recordable.
96
+ *
97
+ * BAPI-1136 widened this from a non-empty-string filter. The filter runs on the
98
+ * read side too, so a malformed path written by an older build (or by hand) is
99
+ * dropped rather than faithfully reserialized — a normalizer that preserves
100
+ * every value it was handed turns one bad write into a permanent one.
101
+ */
68
102
  function normalizeProjectPaths(paths) {
69
103
  const seen = new Set();
70
104
  const out = [];
71
105
  for (const p of paths) {
72
- if (typeof p === "string" && p.length > 0 && !seen.has(p)) {
106
+ if (isRecordableProjectPath(p) && !seen.has(p)) {
73
107
  seen.add(p);
74
108
  out.push(p);
75
109
  }
@@ -337,8 +371,12 @@ async function persistMcpInstallState(cwd, state, finalPath, deps) {
337
371
  * has no field for one, and the serializer emits only schema-approved keys.
338
372
  */
339
373
  export async function recordInstalledProjectArtifact(cwd, relPath, deps) {
340
- if (typeof relPath !== "string" || relPath.length === 0) {
341
- return { ok: false, error: "artifact path must be a non-empty relative path" };
374
+ if (!isRecordableProjectPath(relPath)) {
375
+ return {
376
+ ok: false,
377
+ error: "artifact path must be a repository-relative POSIX path with no absolute prefix, " +
378
+ "traversal segment, empty segment, or backslash, and must not be the install-state file itself",
379
+ };
342
380
  }
343
381
  const existing = await readMcpInstallState(cwd, deps);
344
382
  const priorPlatforms = existing.status === "valid" ? existing.state.selectedPlatforms : [];
@@ -306,21 +306,21 @@ export async function resolveBapiInvocation(projectRoot, deps) {
306
306
  BAPI_PROJECT_ROOT: projectRoot,
307
307
  BAPI_REPO_NAME: repoName,
308
308
  };
309
- // BAPI-527: `CONDUCTOR_NODE_PATH` is NON-SECRET operational metadata (the
309
+ // BAPI-527: `EPIC_IMPLEMENTER_NODE_PATH` is NON-SECRET operational metadata (the
310
310
  // captured conductor Node path used by the worker-ledger CLI subprocess).
311
311
  // `buildChildEnv` already inherits the full parent env, so this re-affirms the
312
312
  // pass-through explicitly and ONLY when present — it is a non-secret allowlist
313
313
  // entry, never a credential. It is emphatically NOT the MCP server's re-exec
314
314
  // command: the spawned command stays `deps.execPath` + `[scriptPath]` below.
315
- const conductorNodePath = deps.env.CONDUCTOR_NODE_PATH;
315
+ const conductorNodePath = deps.env.EPIC_IMPLEMENTER_NODE_PATH;
316
316
  if (typeof conductorNodePath === "string" && conductorNodePath.trim().length > 0) {
317
- overlay.CONDUCTOR_NODE_PATH = conductorNodePath;
317
+ overlay.EPIC_IMPLEMENTER_NODE_PATH = conductorNodePath;
318
318
  }
319
319
  // BAPI-727: overlay the endpoint ONLY when `--base-url` was supplied, mirroring
320
- // the conditional `CONDUCTOR_NODE_PATH` overlay directly above. The absent case
320
+ // the conditional `EPIC_IMPLEMENTER_NODE_PATH` overlay directly above. The absent case
321
321
  // must add, delete, normalize, and override nothing — `buildChildEnv` already
322
322
  // inherits the parent's `BAPI_BASE_URL`, and the server keeps its own production
323
- // default for callers that were never given a URL. Like `CONDUCTOR_NODE_PATH`,
323
+ // default for callers that were never given a URL. Like `EPIC_IMPLEMENTER_NODE_PATH`,
324
324
  // this is non-secret routing metadata, not a credential.
325
325
  if (typeof deps.baseUrl === "string" && deps.baseUrl.length > 0) {
326
326
  overlay.BAPI_BASE_URL = deps.baseUrl;
@@ -15,7 +15,7 @@
15
15
  */
16
16
  const VALID_GROUPS = new Set([
17
17
  "core",
18
- "conductor",
18
+ "epic_implementer",
19
19
  "estimation",
20
20
  "pipeline-authoring",
21
21
  "sfcc",
@@ -49,7 +49,7 @@ export function resolveProfiles(raw) {
49
49
  // rather than an implicit widening of an existing user's surface.
50
50
  return new Set([
51
51
  "core",
52
- "conductor",
52
+ "epic_implementer",
53
53
  "estimation",
54
54
  "pipeline-authoring",
55
55
  "sfcc",
@@ -67,7 +67,7 @@ export function serverNameForMcpTarget(target) {
67
67
  * node executable is a single substitutable field), and the npm-channel fallback
68
68
  * as `{ command: "npx", args: ["-y", "<spec>", "mcp-invoke", …] }`.
69
69
  *
70
- * BAPI-527: `.mcp.json` remains intentionally ENV-FREE. `CONDUCTOR_NODE_PATH`
70
+ * BAPI-527: `.mcp.json` remains intentionally ENV-FREE. `EPIC_IMPLEMENTER_NODE_PATH`
71
71
  * (the captured conductor Node path for the worker-ledger CLI subprocess) is NOT
72
72
  * written here — it is inherited at runtime from the worker SHELL env that
73
73
  * `start-tickets` injects at the spawn boundary, never stored as a registration
@@ -77,7 +77,7 @@ export function serverNameForMcpTarget(target) {
77
77
  *
78
78
  * BAPI-724: the executor caller's `invocation` is resolved BEFORE spawn from its
79
79
  * OWN running process (`resolveMcpShimInvocationForRuntime`, absolute-build-path
80
- * form preferred) and never depends on a worker-injected `CONDUCTOR_NODE_PATH`
80
+ * form preferred) and never depends on a worker-injected `EPIC_IMPLEMENTER_NODE_PATH`
81
81
  * or any other worker-shell env — the executor worker environment carries no
82
82
  * such key at all (see `executor/env.ts`).
83
83
  *
@@ -17,7 +17,7 @@
17
17
  * server file is invoked (`node <absPath>/index.js` vs `npx @…`). It does NOT
18
18
  * change WHICH Node binary runs that file — the `nodeExecutable` field is kept as
19
19
  * a single substitutable value (never string-concatenated into the command) so
20
- * the later Ticket 3 (P2) work can point it at `CONDUCTOR_NODE_PATH` without
20
+ * the later Ticket 3 (P2) work can point it at `EPIC_IMPLEMENTER_NODE_PATH` without
21
21
  * re-plumbing argv construction.
22
22
  */
23
23
  import path from "node:path";
@@ -48,7 +48,7 @@ export function isTransientNpxEntryPath(entryPath) {
48
48
  * Build the concrete `{ command, args }` launch pair for a target + worktree from
49
49
  * a structured invocation. Pure — no filesystem or process access. The
50
50
  * `nodeExecutable` is returned as the standalone `command`, never concatenated
51
- * with the server entry path, so `CONDUCTOR_NODE_PATH` can substitute it later.
51
+ * with the server entry path, so `EPIC_IMPLEMENTER_NODE_PATH` can substitute it later.
52
52
  */
53
53
  export function buildMcpShimCommand(invocation, target, absoluteWorktreePath) {
54
54
  if (invocation.form === "absolute-build-path") {
@@ -141,7 +141,7 @@ function basenameAnySep(filePath) {
141
141
  * NOTE (scope boundary): this ticket changes WHICH server file is invoked, not
142
142
  * WHICH Node runtime runs it. `nodeExecutable` defaults to `"node"` (NOT
143
143
  * `process.execPath`) to preserve today's worker runtime selection; Ticket 3 (P2)
144
- * can later inject `CONDUCTOR_NODE_PATH` into this single field.
144
+ * can later inject `EPIC_IMPLEMENTER_NODE_PATH` into this single field.
145
145
  */
146
146
  export function resolveMcpShimInvocationForRuntime(deps) {
147
147
  const nodeExecutable = deps.nodeExecutable ?? "node";
@@ -78,7 +78,7 @@ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeChe
78
78
  import { MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON, VERDICTLESS_DISPOSITION_FAIL_OPEN, VERDICTLESS_DISPOSITION_PARK, } from "./conductor/git-ci-types.js";
79
79
  import { evaluateReviewCondition, normalizeReviewSnapshot, parseDoneGateConfig, } from "./conductor/done-gate.js";
80
80
  import { buildGateIdentity, makeMergeActionKey } from "./conductor/merge-identity.js";
81
- import { resolveLocalMergeMethod, runApprovedLocalMerge, } from "./conductor/local-merge.js";
81
+ import { resolveLocalMergeMethod, runApprovedLocalMerge, resolveLocalMergeCredential, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, LOCAL_MERGE_CREDENTIAL_MISSING_REASONS, } from "./conductor/local-merge.js";
82
82
  /**
83
83
  * The exact remediation for a `dry_run`. Fixed text, because a caller that has to
84
84
  * infer WHICH setting to flip from a reason code guesses wrong.
@@ -102,6 +102,19 @@ const CI_NOT_GREEN_REASON = "ci_not_green";
102
102
  * moves an unreadable, unsupported, or malformed review configuration.
103
103
  */
104
104
  const REVIEW_NOT_APPROVED_REASON = "review_not_approved";
105
+ /**
106
+ * BAPI-1234 — the server's migration-graph merge guard. Both are named here so
107
+ * the mapping is explicit rather than an accident of the default branch, and
108
+ * both are `needs_human`: repeating the call against the same base and head
109
+ * reproduces the same refusal. `migration_graph_invalid` means a human must
110
+ * renumber the PR's own migration; `migration_graph_unreadable` means the graph
111
+ * could not be proven either way. The exact reason is returned unchanged so the
112
+ * caller can tell them apart; no server-supplied text is added.
113
+ */
114
+ const MIGRATION_GRAPH_REFUSAL_REASONS = new Set([
115
+ "migration_graph_invalid",
116
+ "migration_graph_unreadable",
117
+ ]);
105
118
  // ---------------------------------------------------------------------------
106
119
  // Envelope construction
107
120
  // ---------------------------------------------------------------------------
@@ -560,6 +573,8 @@ function retryHintForFailure(reason, ciSummary) {
560
573
  }
561
574
  if (reason === REVIEW_NOT_APPROVED_REASON)
562
575
  return "retry_later";
576
+ if (MIGRATION_GRAPH_REFUSAL_REASONS.has(reason))
577
+ return "needs_human";
563
578
  return "needs_human";
564
579
  }
565
580
  /**
@@ -614,22 +629,86 @@ function interpretMergeResponse(body, expectedHeadSha, prNumber) {
614
629
  // ---------------------------------------------------------------------------
615
630
  /** The non-terminal status the server returns when it approves a local merge. */
616
631
  const LOCAL_APPROVAL_STATUS = "approved_for_local_execution";
617
- const DEFAULT_MERGE_EXECUTION = "local";
618
- /** Operator-fixable hints for the two preflight refusals. */
632
+ export const DEFAULT_MERGE_EXECUTION = "local";
633
+ /**
634
+ * The ONE place the legacy repository vocabulary is reconciled onto the typed
635
+ * policy vocabulary (BAPI-1133, A2). Exhaustive and pure.
636
+ *
637
+ * `local -> local` is the identity case. `server -> machine_token`, NOT
638
+ * `github_actions`: legacy `server` means the existing direct credentialed
639
+ * provider-API write through the App-mediated merge path
640
+ * (`executeApprovedLocalMerge`'s sibling branch on the server), so its typed
641
+ * equivalent is the direct-credential declaration. `github_actions` represents
642
+ * workflow-DISPATCH intent, which the legacy `server` behavior never performed
643
+ * and so can never be inferred from it. `server` is therefore structurally
644
+ * impossible as a {@link TypedMergeExecutionMode} — this function is the only
645
+ * place that mapping exists, so no other consumer can reimplement it
646
+ * differently.
647
+ */
648
+ export function mapLegacyMergeExecutionModeToPolicyMode(mode) {
649
+ switch (mode) {
650
+ case "local":
651
+ return "local";
652
+ case "server":
653
+ return "machine_token";
654
+ }
655
+ }
656
+ /**
657
+ * Which configured credential a resolved legacy mode reads (BAPI-1136).
658
+ *
659
+ * Routed through {@link mapLegacyMergeExecutionModeToPolicyMode} rather than
660
+ * switching on the legacy value again, so there remains exactly ONE place where
661
+ * legacy `server` becomes typed `machine_token`. Typed `github_actions` never
662
+ * reaches this local path — that mode dispatches the customer's workflow and
663
+ * performs no local subprocess — so it falls back to the local key rather than
664
+ * inventing a third credential.
665
+ */
666
+ export function resolveLocalCredentialMode(mode) {
667
+ const typed = mapLegacyMergeExecutionModeToPolicyMode(mode);
668
+ return typed === "machine_token" ? "machine_token" : "local";
669
+ }
670
+ /**
671
+ * Operator-fixable hints for every preflight and credential refusal.
672
+ *
673
+ * BAPI-1136 removed the `gh auth login` remediation outright. A merge no longer
674
+ * authenticates as whoever is logged into `gh`, so telling an operator to log in
675
+ * would send them to fix a thing that is not consulted — the most expensive kind
676
+ * of wrong hint, because following it appears reasonable and changes nothing.
677
+ *
678
+ * Each hint names the CONFIGURATION KEY to set, never where or how a secret
679
+ * manager should materialize the value: that is the operator's own infrastructure
680
+ * decision, and prescribing one would be guidance this tool cannot verify.
681
+ */
619
682
  const LOCAL_GH_HINTS = {
620
683
  local_gh_unavailable: "Install the GitHub CLI (`gh`) on the machine running this MCP server — local merges execute there.",
621
- local_gh_unauthenticated: "Run `gh auth login` in the shell that hosts this MCP server — local merges use its GitHub session.",
684
+ local_merge_credential_missing: `Set ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} in the environment this MCP server is launched with. ` +
685
+ "It is a configured local scoped credential; Bridge never stores it and there is no ambient-login fallback.",
686
+ machine_merge_credential_missing: `Set ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} in the environment this MCP server is launched with. ` +
687
+ "It is your machine user's credential; Bridge never stores it and there is no ambient-login fallback.",
688
+ local_merge_credential_invalid: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} was rejected by GitHub. Rotate it and relaunch this MCP server.`,
689
+ machine_merge_credential_invalid: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} was rejected by GitHub. Rotate it and relaunch this MCP server.`,
690
+ local_merge_credential_unpermitted: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} authenticates but cannot write to this repository. Grant it merge permission.`,
691
+ machine_merge_credential_unpermitted: `The configured ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} authenticates but cannot write to this repository. Grant the machine user merge permission.`,
622
692
  };
623
693
  /**
624
- * Read the repository's merge execution mode.
694
+ * Read the repository's LEGACY merge execution mode.
625
695
  *
626
696
  * Mode is server-side configuration, never a tool argument: an agent must not be
627
697
  * able to choose who performs a privileged write. An unreadable or unrecognized
628
698
  * value falls back to `local`, which is the mode that works on an installation
629
699
  * without `contents: write` — defaulting to the broken mode on a transient
630
- * config read failure would park a perfectly mergeable pull request.
700
+ * config read failure would park a perfectly mergeable pull request. Total: every
701
+ * failure mode (network error, non-2xx, malformed body, missing/unknown
702
+ * `merge_execution`) resolves to {@link DEFAULT_MERGE_EXECUTION} rather than
703
+ * throwing.
704
+ *
705
+ * Returns only the legacy vocabulary — `local | server` — never a typed mode.
706
+ * `setup-epic.ts` maps this result through
707
+ * {@link mapLegacyMergeExecutionModeToPolicyMode} before composing it into a
708
+ * `RunPolicy` declaration; `mergePullRequestHandler` sends it straight to the
709
+ * existing merge route unchanged.
631
710
  */
632
- async function resolveMergeExecutionMode(deps) {
711
+ export async function resolveMergeExecutionMode(deps) {
633
712
  try {
634
713
  const resp = await (deps.fetchImpl ?? fetch)(deps.buildGetUrl("/epic-runs/supervisor-config/defaults/", { repo_name: deps.repoName }), { headers: await deps.getHeaders() });
635
714
  if (!resp.ok)
@@ -703,7 +782,7 @@ async function reportLocalCompletion(deps, prNumber, body) {
703
782
  * left to time out; and a refusal here is always `needs_human`, because every
704
783
  * local failure this can produce is an operator fix, not a wait.
705
784
  */
706
- async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadSha, actionKey) {
785
+ async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadSha, actionKey, credentialMode) {
707
786
  if (localApprovalMismatch(approval, prNumber, expectedHeadSha, actionKey)) {
708
787
  return envelope(false, "refused", "local_approval_mismatch", "needs_human", expectedHeadSha, prNumber);
709
788
  }
@@ -715,10 +794,34 @@ async function executeApprovedLocalMerge(deps, approval, prNumber, expectedHeadS
715
794
  gate: { name: DEFAULT_GATE_NAME },
716
795
  action_key: actionKey,
717
796
  };
797
+ // BAPI-1136. The credential is resolved HERE, before the executor is called,
798
+ // so an unconfigured mode refuses without spawning a privileged subprocess —
799
+ // and so the refusal names which key to set rather than surfacing whatever
800
+ // `gh` says when it has no identity.
801
+ const credential = resolveLocalMergeCredential(deps.runtimeEnv ?? process.env, credentialMode);
802
+ if (credential === null) {
803
+ const reason = LOCAL_MERGE_CREDENTIAL_MISSING_REASONS[credentialMode];
804
+ // The lease is still closed: an attempt that cannot run is an attempt that
805
+ // is over, and leaving the ledger row open would make the reconciler wait
806
+ // out a lease for a merge nobody is going to perform.
807
+ await reportLocalCompletion(deps, prNumber, {
808
+ repo_name: deps.repoName,
809
+ action_key: actionKey,
810
+ expected_head_sha: expectedHeadSha,
811
+ result: "failed",
812
+ reason,
813
+ });
814
+ const missingHint = LOCAL_GH_HINTS[reason];
815
+ return envelope(false, "refused", reason, "needs_human", expectedHeadSha, prNumber, {
816
+ ...(missingHint ? { hint: missingHint } : {}),
817
+ });
818
+ }
718
819
  let local;
719
820
  try {
720
821
  const runLocal = deps.runLocalMerge ?? runApprovedLocalMerge;
721
- local = await runLocal(request, { method }, { env: process.env });
822
+ // Exactly the resolved credential — never `{ env: process.env }`, which is
823
+ // how the ambient identity used to reach the subprocess.
824
+ local = await runLocal(request, { method }, { credential });
722
825
  }
723
826
  catch {
724
827
  // The executor itself threw. Treat it exactly like a reported failure: close
@@ -876,7 +979,12 @@ export async function mergePullRequestHandler(deps, args) {
876
979
  if (executionMode === "local" &&
877
980
  isPlainObject(mergeJson) &&
878
981
  mergeJson.status === LOCAL_APPROVAL_STATUS) {
879
- return text(withReviewWaiver(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey), reviewWaiver));
982
+ return text(withReviewWaiver(await executeApprovedLocalMerge(deps, mergeJson, prNumber, expectedHeadSha, actionKey,
983
+ // The AUTHORITATIVE typed axis decides which credential is read.
984
+ // Legacy `server` maps to typed `machine_token` through the one
985
+ // existing mapping function; nothing here re-derives that, and no
986
+ // tool argument can influence it.
987
+ resolveLocalCredentialMode(executionMode)), reviewWaiver));
880
988
  }
881
989
  return text(withReviewWaiver(interpretMergeResponse(mergeJson, expectedHeadSha, prNumber), reviewWaiver));
882
990
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Customer-owned review artifacts for the merge workflow (BAPI-1136).
3
+ *
4
+ * The merge workflow is only as safe as the review gate on its path. This module
5
+ * renders the two artifacts that make that gate possible, and it renders NOTHING
6
+ * that enforces it — enforcement is a repository setting only the customer can
7
+ * apply.
8
+ *
9
+ * ## Why a fragment and not an edit to `.github/CODEOWNERS`
10
+ *
11
+ * A repository's CODEOWNERS file is live policy. An installer that rewrote it
12
+ * could reassign ownership of unrelated paths, drop a rule whose syntax it
13
+ * misparsed, or resolve a conflict nobody asked it to resolve — and it would do
14
+ * so inside a pull request whose reviewer is reading a workflow file, not a
15
+ * policy diff. So this emits a self-contained FRAGMENT at a dedicated path and
16
+ * tells the customer to merge it themselves. The fragment is real, reviewable
17
+ * CODEOWNERS syntax; it simply is not yet in force.
18
+ *
19
+ * ## Why the guidance is plain text
20
+ *
21
+ * It is read in a pull-request diff, in a terminal, and by a screen reader. So:
22
+ * descriptive headings, explicit "Required:" labels, short paragraphs, stable
23
+ * ordering, and no code block that needs horizontal scrolling.
24
+ *
25
+ * ## Secret discipline
26
+ *
27
+ * Neither renderer accepts, embeds, or invites a secret VALUE. The Bridge API
28
+ * key is named only — `BAPI_API_KEY` — and every command shown is one that
29
+ * cannot take a credential on argv.
30
+ */
31
+ import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, } from "./conductor/merge-execution-contract.js";
32
+ import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME, } from "./merge-workflow-config.js";
33
+ /** Repo-relative path of the generated CODEOWNERS fragment. */
34
+ export const MERGE_WORKFLOW_CODEOWNERS_RELPATH = ".bridge/merge-workflow/CODEOWNERS.fragment";
35
+ /** Repo-relative path of the generated repository-rule guidance. */
36
+ export const MERGE_WORKFLOW_GUIDANCE_RELPATH = ".bridge/merge-workflow/REPOSITORY-RULES.md";
37
+ /**
38
+ * Every repo-relative path this installer may write, in the order a reader
39
+ * should see them. Exported as the single manifest so the preflight, the
40
+ * consent summary, the commit, and the install-state record cannot disagree
41
+ * about what "the managed artifacts" are.
42
+ */
43
+ export const MERGE_WORKFLOW_MANAGED_RELPATHS = [
44
+ MERGE_WORKFLOW_PATH,
45
+ MERGE_WORKFLOW_CODEOWNERS_RELPATH,
46
+ MERGE_WORKFLOW_GUIDANCE_RELPATH,
47
+ ];
48
+ /** Thrown when a code-owner value fails validation. Carries no rendered output. */
49
+ export class MergeWorkflowCodeOwnerError extends Error {
50
+ constructor(message) {
51
+ super(message);
52
+ this.name = "MergeWorkflowCodeOwnerError";
53
+ }
54
+ }
55
+ /**
56
+ * A GitHub code owner: `@user` or `@org/team`.
57
+ *
58
+ * Deliberately rejects whitespace, quotes, `#` (which would comment out the rest
59
+ * of the CODEOWNERS line), `$`, `{`, `}`, and any second `/`. The pattern is an
60
+ * allowlist rather than a denylist so a character nobody thought about is
61
+ * rejected rather than rendered.
62
+ */
63
+ const CODE_OWNER_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
64
+ const MAX_CODE_OWNER_LENGTH = 100;
65
+ /**
66
+ * Values that parse as a code owner but are obviously a placeholder someone
67
+ * pasted from documentation. Accepting one would open a pull request assigning
68
+ * review of a write-enabled workflow to an account that does not exist, which
69
+ * reads as protected while protecting nothing.
70
+ */
71
+ const PLACEHOLDER_CODE_OWNERS = new Set([
72
+ "@org/team",
73
+ "@owner/team",
74
+ "@your-org/your-team",
75
+ "@user",
76
+ "@username",
77
+ "@your-username",
78
+ "@example",
79
+ "@example/team",
80
+ ]);
81
+ /**
82
+ * Validate a single code owner.
83
+ *
84
+ * Runs BEFORE any filesystem or git mutation — an invalid owner must cost
85
+ * nothing, and must never leave a half-written branch behind.
86
+ *
87
+ * @throws MergeWorkflowCodeOwnerError naming the problem, never echoing a value
88
+ * longer than the bound above.
89
+ */
90
+ export function validateMergeWorkflowCodeOwner(value) {
91
+ if (typeof value !== "string") {
92
+ throw new MergeWorkflowCodeOwnerError("--codeowner requires a value");
93
+ }
94
+ const trimmed = value.trim();
95
+ if (trimmed.length === 0) {
96
+ throw new MergeWorkflowCodeOwnerError("--codeowner requires a non-empty value");
97
+ }
98
+ if (trimmed.length > MAX_CODE_OWNER_LENGTH) {
99
+ throw new MergeWorkflowCodeOwnerError("--codeowner value is too long");
100
+ }
101
+ if (/\s/.test(trimmed)) {
102
+ throw new MergeWorkflowCodeOwnerError("--codeowner takes exactly one owner; supply a single @user or @org/team");
103
+ }
104
+ if (!trimmed.startsWith("@")) {
105
+ throw new MergeWorkflowCodeOwnerError("--codeowner must start with @ (e.g. @org/team or @user)");
106
+ }
107
+ if (!CODE_OWNER_PATTERN.test(trimmed)) {
108
+ throw new MergeWorkflowCodeOwnerError("--codeowner must be a GitHub @user or @org/team handle");
109
+ }
110
+ if (PLACEHOLDER_CODE_OWNERS.has(trimmed.toLowerCase())) {
111
+ throw new MergeWorkflowCodeOwnerError("--codeowner looks like a documentation placeholder; supply the real reviewing team or user");
112
+ }
113
+ return trimmed;
114
+ }
115
+ /**
116
+ * Render the CODEOWNERS fragment for the merge-workflow path.
117
+ *
118
+ * Deterministic for a given owner, newline-terminated, and covering exactly one
119
+ * path. It claims no other file.
120
+ */
121
+ export function renderMergeWorkflowCodeownersFragment(codeOwner) {
122
+ const owner = validateMergeWorkflowCodeOwner(codeOwner);
123
+ return [
124
+ "# Bridge-managed CODEOWNERS fragment for the Epic Implementer merge workflow.",
125
+ "#",
126
+ "# This file is NOT a CODEOWNERS file. GitHub reads CODEOWNERS only from",
127
+ "# .github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS, so nothing here is in",
128
+ "# force until you copy the rule below into the file your repository actually",
129
+ "# uses. It is generated separately so an installer never rewrites live review",
130
+ "# policy on your behalf.",
131
+ "#",
132
+ "# The rule exists because " + MERGE_WORKFLOW_PATH + " is the one file in this",
133
+ "# repository that can perform a privileged merge. Whoever can change it can",
134
+ "# change what merges.",
135
+ "",
136
+ MERGE_WORKFLOW_PATH + " " + owner,
137
+ "",
138
+ ].join("\n");
139
+ }
140
+ /**
141
+ * Render the repository-rule guidance document.
142
+ *
143
+ * Deterministic for a given owner. States, in order: what was generated, what
144
+ * the customer must do, and what is NOT protected until they do it.
145
+ */
146
+ export function renderMergeWorkflowGuidance(codeOwner) {
147
+ const owner = validateMergeWorkflowCodeOwner(codeOwner);
148
+ return [
149
+ "# Epic Implementer merge workflow — required repository setup",
150
+ "",
151
+ "Bridge generated three files in this pull request. Merging the pull request",
152
+ "installs them. It does not protect them: two of the three steps below are",
153
+ "repository settings that only you can apply.",
154
+ "",
155
+ "## What was generated",
156
+ "",
157
+ "- `" + MERGE_WORKFLOW_PATH + "` — the merge workflow itself, contract version " +
158
+ String(MERGE_WORKFLOW_EXPECTED_VERSION) + ".",
159
+ "- `" + MERGE_WORKFLOW_CODEOWNERS_RELPATH + "` — a CODEOWNERS rule for that path.",
160
+ "- `" + MERGE_WORKFLOW_GUIDANCE_RELPATH + "` — this document.",
161
+ "",
162
+ "## Required: create the repository secret",
163
+ "",
164
+ "Required. The workflow authenticates to Bridge with a repository-level Actions",
165
+ "secret named `" + MERGE_WORKFLOW_API_KEY_SECRET_NAME + "`, holding a Bridge API",
166
+ "key for this repository.",
167
+ "",
168
+ "Create it through GitHub's own secret interface — repository Settings, then",
169
+ "Secrets and variables, then Actions. Bridge never sees, transmits, stores, or",
170
+ "writes the value: the installer that opened this pull request has no flag, no",
171
+ "prompt, and no file field for a credential, and the value must never be",
172
+ "committed, printed, or pasted into a project file.",
173
+ "",
174
+ "Optional: set a repository Actions VARIABLE named",
175
+ "`" + MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME + "` if this repository talks to a",
176
+ "Bridge deployment other than the default. A variable, not a secret, and not a",
177
+ "workflow input — the base URL decides which server authorizes a merge, so it",
178
+ "is repository configuration rather than something a caller supplies per run.",
179
+ "",
180
+ "## Required: merge the CODEOWNERS rule into your active CODEOWNERS file",
181
+ "",
182
+ "Required. GitHub reads code owners only from `.github/CODEOWNERS`,",
183
+ "`CODEOWNERS`, or `docs/CODEOWNERS`. The generated fragment sits at",
184
+ "`" + MERGE_WORKFLOW_CODEOWNERS_RELPATH + "` and enforces nothing where it is.",
185
+ "",
186
+ "Copy its single rule into the CODEOWNERS file your repository actually uses,",
187
+ "keeping your existing rules intact. The rule is:",
188
+ "",
189
+ "```",
190
+ MERGE_WORKFLOW_PATH + " " + owner,
191
+ "```",
192
+ "",
193
+ "CODEOWNERS is last-match-wins, so place it after any broader rule that also",
194
+ "matches `.github/workflows/`.",
195
+ "",
196
+ "## Required: add the default-branch repository rule",
197
+ "",
198
+ "Required. A CODEOWNERS entry requests a reviewer. It does not require one.",
199
+ "Until you add a rule, anyone who can push to the default branch can rewrite",
200
+ "the merge workflow.",
201
+ "",
202
+ "In repository Settings, under Rules, add a ruleset targeting the default",
203
+ "branch that requires a pull request before merging and requires review from",
204
+ "code owners. Restricting who can bypass the ruleset matters as much as the",
205
+ "rule itself.",
206
+ "",
207
+ "## What is not protected until you finish",
208
+ "",
209
+ "Enforcement is absent until the repository rule above is in place. Between",
210
+ "merging this pull request and applying that rule, the workflow is installed",
211
+ "and reviewable but its path is not review-gated.",
212
+ "",
213
+ "## If something fails later",
214
+ "",
215
+ "Bridge refuses to dispatch a merge when it cannot detect the workflow, or when",
216
+ "the workflow's contract version is not the one Bridge understands. Escalate",
217
+ "both to a repository administrator — they are the only person who can check",
218
+ "that the workflow is present on the default branch, that Actions is enabled",
219
+ "for this repository, and that the file has not been hand-edited. Re-running",
220
+ "the installer reports which of the four states the file is in and never",
221
+ "overwrites a file it did not write.",
222
+ "",
223
+ ].join("\n");
224
+ }