@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -112,45 +112,16 @@ export function resolveLocalMergeMethod(value) {
112
112
  ? value
113
113
  : "squash";
114
114
  }
115
- /**
116
- * The one process-launch configuration key each credential kind reads.
117
- *
118
- * There is NO fallback between them and no fallback to anything else: not to an
119
- * inherited `GH_TOKEN` or `GITHUB_TOKEN`, not to a `gh auth` session, and not to
120
- * the Bridge credential store. A merge mode that silently borrowed the other
121
- * mode's credential would make "which identity performed this write" unanswerable
122
- * from the run's own configuration, which is the property this whole change
123
- * exists to establish.
124
- *
125
- * Neither key is ever routed through `resolveBapiCredentials`,
126
- * `resolveCredentialBundle`, project configuration, or install state. A GitHub
127
- * merge credential is not a Bridge credential and must not acquire Bridge's
128
- * persistence.
129
- */
130
- export const LOCAL_MERGE_CREDENTIAL_ENV_KEYS = {
131
- local: "BAPI_LOCAL_MERGE_TOKEN",
132
- machine_token: "BAPI_MACHINE_MERGE_TOKEN",
133
- };
115
+ // BAPI-1452: the credential kinds, keys, and resolver live in a dependency-free
116
+ // module so read-only callers need not import this executor; re-exported here
117
+ // so this module's public surface is unchanged.
118
+ import { LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, resolveLocalMergeCredential, resolveLocalMergeCredentialDetailed, } from "./merge-credential.js";
119
+ export { LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY, LOCAL_MERGE_CREDENTIAL_ENV_KEYS, resolveLocalMergeCredential, resolveLocalMergeCredentialDetailed, };
134
120
  /** The named, mode-specific refusal when the configured credential is absent. */
135
121
  export const LOCAL_MERGE_CREDENTIAL_MISSING_REASONS = {
136
122
  local: "local_merge_credential_missing",
137
123
  machine_token: "machine_merge_credential_missing",
138
124
  };
139
- /**
140
- * Resolve the one credential a kind is allowed to read.
141
- *
142
- * Returns `null` for an absent or blank value — never the other kind's value and
143
- * never an inherited one.
144
- */
145
- export function resolveLocalMergeCredential(env, kind) {
146
- const raw = env[LOCAL_MERGE_CREDENTIAL_ENV_KEYS[kind]];
147
- if (typeof raw !== "string")
148
- return null;
149
- const token = raw.trim();
150
- if (token.length === 0)
151
- return null;
152
- return { kind, token };
153
- }
154
125
  /**
155
126
  * Environment variables the `gh` child is allowed to inherit.
156
127
  *
@@ -205,6 +176,8 @@ const CHILD_ENV_ALLOWLIST = [
205
176
  const CHILD_ENV_DENYLIST = new Set([
206
177
  "GH_TOKEN",
207
178
  "GITHUB_TOKEN",
179
+ // BAPI-1452: the canonical local key, alongside its deprecated alias.
180
+ "GITHUB_MERGE_TOKEN",
208
181
  "BAPI_LOCAL_MERGE_TOKEN",
209
182
  "BAPI_MACHINE_MERGE_TOKEN",
210
183
  ]);
@@ -1013,19 +986,141 @@ async function resolveProbeRepository(run, ghEnv, supplied) {
1013
986
  const resolved = view.stdout.trim();
1014
987
  return OWNER_NAME_PATTERN.test(resolved) ? resolved : null;
1015
988
  }
1016
- /** Mode-specific reason triples, so no call site re-derives a spelling. */
989
+ /** Map a preflight result to its kind-independent verdict (BAPI-1452). */
990
+ export function classifyLocalGhPreflight(result) {
991
+ if (result.ok)
992
+ return "passed";
993
+ switch (result.reason) {
994
+ case "local_gh_unavailable":
995
+ return "gh_unavailable";
996
+ case "local_merge_credential_missing":
997
+ case "machine_merge_credential_missing":
998
+ return "credential_missing";
999
+ case "local_merge_credential_invalid":
1000
+ case "machine_merge_credential_invalid":
1001
+ return "credential_invalid";
1002
+ case "local_merge_credential_unpermitted":
1003
+ case "machine_merge_credential_unpermitted":
1004
+ return "no_access";
1005
+ case "local_merge_probe_unavailable":
1006
+ case "machine_merge_probe_unavailable":
1007
+ return "probe_unavailable";
1008
+ }
1009
+ }
1010
+ /** Mode-specific reason sets, so no call site re-derives a spelling. */
1017
1011
  const PREFLIGHT_REASONS = {
1018
1012
  local: {
1019
1013
  missing: "local_merge_credential_missing",
1020
1014
  invalid: "local_merge_credential_invalid",
1021
1015
  unpermitted: "local_merge_credential_unpermitted",
1016
+ unavailable: "local_merge_probe_unavailable",
1022
1017
  },
1023
1018
  machine_token: {
1024
1019
  missing: "machine_merge_credential_missing",
1025
1020
  invalid: "machine_merge_credential_invalid",
1026
1021
  unpermitted: "machine_merge_credential_unpermitted",
1022
+ unavailable: "machine_merge_probe_unavailable",
1027
1023
  },
1028
1024
  };
1025
+ // --------------------------------------------------------------------------- //
1026
+ // BAPI-1452: bounded parsing of one `gh api --include` response
1027
+ // --------------------------------------------------------------------------- //
1028
+ /** Status line of an `--include` response: anchored, first line only. */
1029
+ const HTTP_STATUS_LINE_PATTERN = /^HTTP\/\S+ ([0-9]{3})(?:\s|$)/;
1030
+ /** `gh`'s own error suffix, e.g. `gh: Not Found (HTTP 404)`. Bounded digits. */
1031
+ const GH_STDERR_STATUS_PATTERN = /\(HTTP ([0-9]{3})\)/;
1032
+ /** One response header line. Anything else in the header block is malformed. */
1033
+ const HTTP_HEADER_LINE_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[^\n]*$/;
1034
+ /**
1035
+ * Hard cap on the output a probe will parse. A repository record or a one-item
1036
+ * pull-request page is tens of kilobytes; anything near this is not a response
1037
+ * this probe understands.
1038
+ */
1039
+ const MAX_PROBE_OUTPUT_CHARS = 2_000_000;
1040
+ /** Bounded prefix of stderr searched for the fallback status. */
1041
+ const MAX_PROBE_STDERR_SCAN_CHARS = 4_096;
1042
+ /** Parse a JSON body, returning `undefined` when it is not valid JSON. */
1043
+ function parseProbeBody(body) {
1044
+ try {
1045
+ return JSON.parse(body);
1046
+ }
1047
+ catch {
1048
+ return undefined;
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Run one `gh api --include` read and reduce it to a status plus parsed body.
1053
+ *
1054
+ * The status comes ONLY from stdout's first line. When a non-zero command left
1055
+ * no usable stdout status, the bounded `(HTTP nnn)` suffix on the (already
1056
+ * sanitized) stderr is the single permitted fallback, and it yields no body. A
1057
+ * non-2xx status needs no body; a 200 must carry a parseable JSON body or the
1058
+ * read is malformed.
1059
+ */
1060
+ async function readProbeResponse(run, ghEnv, endpoint) {
1061
+ let result;
1062
+ try {
1063
+ result = await run("gh", ["api", "--include", endpoint], ghEnv);
1064
+ }
1065
+ catch {
1066
+ return { kind: "unavailable", reason: "probe_transport_failed" };
1067
+ }
1068
+ if (result.timedOut === true)
1069
+ return { kind: "unavailable", reason: "probe_timed_out" };
1070
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
1071
+ if (stdout.length > MAX_PROBE_OUTPUT_CHARS) {
1072
+ return { kind: "unavailable", reason: "probe_response_malformed" };
1073
+ }
1074
+ const normalized = stdout.replace(/\r\n/g, "\n");
1075
+ const firstLineEnd = normalized.indexOf("\n");
1076
+ const firstLine = firstLineEnd === -1 ? normalized : normalized.slice(0, firstLineEnd);
1077
+ const statusMatch = HTTP_STATUS_LINE_PATTERN.exec(firstLine);
1078
+ if (!statusMatch) {
1079
+ if (result.status !== 0 && normalized.trim().length === 0) {
1080
+ const stderr = sanitizeLocalMergeOutput(typeof result.stderr === "string" ? result.stderr : "", [ghEnv.GH_TOKEN]).slice(0, MAX_PROBE_STDERR_SCAN_CHARS);
1081
+ const fallback = GH_STDERR_STATUS_PATTERN.exec(stderr);
1082
+ if (fallback)
1083
+ return { kind: "status", status: Number(fallback[1]), body: undefined };
1084
+ // A failed command that produced no status anywhere is a transport-level
1085
+ // failure, never evidence of denied access.
1086
+ return { kind: "unavailable", reason: "probe_transport_failed" };
1087
+ }
1088
+ // Unexpected non-empty output, or an exit-0 read with nothing to parse.
1089
+ return { kind: "unavailable", reason: "probe_response_malformed" };
1090
+ }
1091
+ const status = Number(statusMatch[1]);
1092
+ const separator = normalized.indexOf("\n\n");
1093
+ const headerBlock = separator === -1 ? normalized.slice(firstLine.length) : normalized.slice(firstLine.length, separator);
1094
+ for (const line of headerBlock.split("\n")) {
1095
+ if (line.length === 0)
1096
+ continue;
1097
+ if (!HTTP_HEADER_LINE_PATTERN.test(line)) {
1098
+ return { kind: "unavailable", reason: "probe_response_malformed" };
1099
+ }
1100
+ }
1101
+ const bodyText = separator === -1 ? "" : normalized.slice(separator + 2).trim();
1102
+ if (status !== 200)
1103
+ return { kind: "status", status, body: undefined };
1104
+ const body = parseProbeBody(bodyText);
1105
+ if (body === undefined)
1106
+ return { kind: "unavailable", reason: "probe_response_malformed" };
1107
+ return { kind: "status", status, body };
1108
+ }
1109
+ /** The repository-metadata decision: push, proven no-push, or unusable. */
1110
+ function readPushPermission(body) {
1111
+ if (body === null || typeof body !== "object" || Array.isArray(body))
1112
+ return "malformed";
1113
+ const permissions = body.permissions;
1114
+ if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) {
1115
+ return "malformed";
1116
+ }
1117
+ const push = permissions.push;
1118
+ if (push === true)
1119
+ return "push";
1120
+ if (push === false)
1121
+ return "no_push";
1122
+ return "malformed";
1123
+ }
1029
1124
  /**
1030
1125
  * Probe whether this host can merge with the CONFIGURED credential, before any
1031
1126
  * merge is attempted.
@@ -1043,17 +1138,24 @@ const PREFLIGHT_REASONS = {
1043
1138
  * credential this run was configured with usable HERE, for THIS repository",
1044
1139
  * which is the question whose answer predicts the merge.
1045
1140
  *
1046
- * Four outcomes, each fail-closed:
1141
+ * Five outcomes, each fail-closed for a merge:
1047
1142
  *
1048
1143
  * 1. `gh` cannot be invoked -> `local_gh_unavailable`.
1049
1144
  * 2. No credential configured -> the kind's `*_credential_missing`, returned
1050
1145
  * BEFORE any privileged subprocess runs.
1051
1146
  * 3. `gh auth status` rejects the credential -> the kind's `*_credential_invalid`.
1052
- * 4. The repository permission probe does not prove write access -> the kind's
1053
- * `*_credential_unpermitted`.
1147
+ * 4. PROVEN no access -> the kind's `*_credential_unpermitted`.
1148
+ * 5. BAPI-1452: no usable evidence -> the kind's `*_probe_unavailable`, with a
1149
+ * closed {@link LocalGhProbeUnavailableReason}.
1054
1150
  *
1055
- * A network failure or an unparseable capability response is bounded and lands
1056
- * on (4) rather than passing: an unprovable permission is not permission.
1151
+ * BAPI-1452 made the capability probe two bounded reads with `--include`:
1152
+ * `repos/{owner}/{name}` (the push permission is read ONLY here) and
1153
+ * `repos/{owner}/{name}/pulls?per_page=1` (proves pull requests are readable).
1154
+ * Only a 404 on either read, or a well-formed 200 metadata record whose
1155
+ * `permissions.push` is `false`, proves no access. 401, 403, 429, 5xx, any
1156
+ * other status, a transport failure, a timeout, and malformed output are all
1157
+ * `probe_unavailable` — a non-zero exit alone is never read as denial. No read
1158
+ * is retried; each runs under the runner's existing wall-clock timeout.
1057
1159
  *
1058
1160
  * Deliberately async and built on the same `run` seam as the merge itself. The
1059
1161
  * read-only equivalent in `conductor/doctor.ts` (`inspectLocalMerge`) probes
@@ -1076,6 +1178,8 @@ export async function preflightLocalGh(run, ghEnv, context = { kind: "local" })
1076
1178
  // The credential check comes AFTER the capability check and BEFORE every
1077
1179
  // privileged call: "gh is missing" and "gh is fine but nothing configured it"
1078
1180
  // are different fixes, and neither should cost an authenticated request.
1181
+ if (context.credentialInvalid === true)
1182
+ return { ok: false, reason: reasons.invalid };
1079
1183
  const credentialPresent = context.credentialPresent ?? (typeof ghEnv.GH_TOKEN === "string" && ghEnv.GH_TOKEN.length > 0);
1080
1184
  if (!credentialPresent)
1081
1185
  return { ok: false, reason: reasons.missing };
@@ -1091,25 +1195,106 @@ export async function preflightLocalGh(run, ghEnv, context = { kind: "local" })
1091
1195
  // A credential can authenticate and still be unable to merge — a read-scoped
1092
1196
  // token, or a machine user who was never granted write on this repository.
1093
1197
  // That distinction is the difference between "rotate the token" and "grant the
1094
- // account access", so it is worth one read-only request to establish.
1198
+ // account access", so it is worth two read-only requests to establish.
1095
1199
  const repository = await resolveProbeRepository(run, ghEnv, context.repository);
1096
- if (repository !== null) {
1097
- let probe;
1098
- try {
1099
- probe = await run("gh", ["api", `repos/${repository}`, "--jq", ".permissions.push"], ghEnv);
1100
- }
1101
- catch {
1102
- return { ok: false, reason: reasons.unpermitted };
1103
- }
1104
- if (probe.status !== 0)
1105
- return { ok: false, reason: reasons.unpermitted };
1106
- // Bounded parse. Anything that is not an explicit `true` fails closed; raw
1107
- // `gh` output is never surfaced, only this verdict.
1108
- if (probe.stdout.trim() !== "true")
1109
- return { ok: false, reason: reasons.unpermitted };
1110
- }
1200
+ if (repository === null)
1201
+ return { ok: true };
1202
+ const unavailable = (probeReason) => ({
1203
+ ok: false,
1204
+ reason: reasons.unavailable,
1205
+ probe_unavailable_reason: probeReason,
1206
+ });
1207
+ const metadata = await readProbeResponse(run, ghEnv, `repos/${repository}`);
1208
+ if (metadata.kind === "unavailable")
1209
+ return unavailable(metadata.reason);
1210
+ if (metadata.status === 404)
1211
+ return { ok: false, reason: reasons.unpermitted };
1212
+ if (metadata.status !== 200)
1213
+ return unavailable("probe_provider_status_inconclusive");
1214
+ const push = readPushPermission(metadata.body);
1215
+ if (push === "malformed")
1216
+ return unavailable("probe_response_malformed");
1217
+ if (push === "no_push")
1218
+ return { ok: false, reason: reasons.unpermitted };
1219
+ const pulls = await readProbeResponse(run, ghEnv, `repos/${repository}/pulls?per_page=1`);
1220
+ if (pulls.kind === "unavailable")
1221
+ return unavailable(pulls.reason);
1222
+ if (pulls.status === 404)
1223
+ return { ok: false, reason: reasons.unpermitted };
1224
+ if (pulls.status !== 200)
1225
+ return unavailable("probe_provider_status_inconclusive");
1226
+ // Push permission is read only from the repository record above; the pull
1227
+ // request page only has to have the expected shape.
1228
+ if (!Array.isArray(pulls.body))
1229
+ return unavailable("probe_response_malformed");
1111
1230
  return { ok: true };
1112
1231
  }
1232
+ /**
1233
+ * BAPI-1452 — the launch-time capability probe, with the SAME credential
1234
+ * resolution and the SAME `gh` runner a merge uses.
1235
+ *
1236
+ * Resolves the kind's credential from `env` (canonical key, then — for the
1237
+ * local kind only — the deprecated alias), builds the allowlisted child
1238
+ * environment, and runs {@link preflightLocalGh} against an explicit,
1239
+ * validated `owner/name`. Returns only the verdict and closed reason; the
1240
+ * token and every byte of subprocess output stay inside this call.
1241
+ *
1242
+ * Read-only: `gh --version`, `gh auth status`, and two `gh api` GETs.
1243
+ */
1244
+ export async function probeLocalMergeCapability(context, deps = {}) {
1245
+ const env = deps.env ?? process.env;
1246
+ let credential;
1247
+ let invalid;
1248
+ if (context.resolved !== undefined) {
1249
+ credential = context.resolved.credential;
1250
+ invalid = context.resolved.invalid;
1251
+ }
1252
+ else {
1253
+ const resolution = resolveLocalMergeCredentialDetailed(env, context.kind);
1254
+ credential = resolution.status === "resolved" ? resolution.credential : null;
1255
+ invalid = resolution.status === "invalid";
1256
+ }
1257
+ const ghEnv = buildLocalMergeChildEnv(env, credential);
1258
+ const rawRun = deps.runCommand ?? defaultRunCommand;
1259
+ const run = (cmd, args, childEnv) => Promise.resolve(rawRun(cmd, args, childEnv, deps.signal));
1260
+ return preflightLocalGh(run, ghEnv, {
1261
+ kind: context.kind,
1262
+ repository: context.repository,
1263
+ credentialPresent: credential !== null,
1264
+ credentialInvalid: invalid,
1265
+ });
1266
+ }
1267
+ /**
1268
+ * BAPI-1453 — re-run the capability probe for the merge-job path, with the
1269
+ * EXACT credential, environment base, runner, and abort signal a failed merge
1270
+ * just used (the {@link LocalMergeDeps} it was built from).
1271
+ *
1272
+ * Unlike {@link probeLocalMergeCapability} this resolves no credential of its
1273
+ * own: the question is whether the credential that just failed `gh pr view` can
1274
+ * see the repository, so asking about any other credential would answer the
1275
+ * wrong question. `repository` must be the server-carried provider `owner/name`;
1276
+ * there is no `gh repo view` fallback on this path, so a caller without one
1277
+ * must not call this at all.
1278
+ *
1279
+ * Read-only and value-free: returns only the verdict and closed reason.
1280
+ */
1281
+ export async function probeLocalMergeRepositoryAccess(repository, deps) {
1282
+ const credential = deps.credential ?? null;
1283
+ const reasons = PREFLIGHT_REASONS[credential?.kind ?? "local"];
1284
+ // `preflightLocalGh` falls back to `gh repo view` for a value it cannot use.
1285
+ // That fallback is forbidden here, so an unusable value is "no evidence".
1286
+ if (typeof repository !== "string" || !OWNER_NAME_PATTERN.test(repository)) {
1287
+ return { ok: false, reason: reasons.unavailable, probe_unavailable_reason: "probe_response_malformed" };
1288
+ }
1289
+ const ghEnv = buildLocalMergeChildEnv(deps.env ?? process.env, credential);
1290
+ const rawRun = deps.runCommand ?? defaultRunCommand;
1291
+ const run = (cmd, args, childEnv) => Promise.resolve(rawRun(cmd, args, childEnv, deps.signal));
1292
+ return preflightLocalGh(run, ghEnv, {
1293
+ kind: credential?.kind ?? "local",
1294
+ repository,
1295
+ credentialPresent: credential !== null,
1296
+ });
1297
+ }
1113
1298
  /**
1114
1299
  * Execute a merge the server has ALREADY approved, using the operator's `gh`.
1115
1300
  *
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Merge credential configuration keys and their resolution (BAPI-1452).
3
+ *
4
+ * Split out of `local-merge.ts` so read-only callers — `doctor`, the shared
5
+ * readiness requirements, and installer guidance — can name the keys and
6
+ * resolve presence WITHOUT importing the merge executor and its process
7
+ * runner. Dependency-free: no `child_process`, no filesystem, no network.
8
+ * `local-merge.ts` re-exports everything here, so its public surface is
9
+ * unchanged.
10
+ *
11
+ * BAPI-1461 adds {@link resolveLocalMergeCredentialDetailedAsync}: the same
12
+ * ladder plus ONE more rung for the local kind only — a token stored once in
13
+ * the user-scoped credential store under `github-merge:github.com/<owner>/<repo>`.
14
+ * The store is read through an injected reader, so this module still has no
15
+ * filesystem, process, or network dependency. The two synchronous resolvers
16
+ * below are unchanged, so every caller that stays env-only keeps its exact
17
+ * behavior, and machine-user mode never gains persistence.
18
+ */
19
+ /**
20
+ * The one process-launch configuration key each credential kind reads.
21
+ *
22
+ * There is NO fallback between them and no fallback to anything else: not to an
23
+ * inherited `GH_TOKEN` or `GITHUB_TOKEN`, not to a `gh auth` session, and not to
24
+ * the Bridge credential store. A merge mode that silently borrowed the other
25
+ * mode's credential would make "which identity performed this write" unanswerable
26
+ * from the run's own configuration, which is the property this whole change
27
+ * exists to establish.
28
+ *
29
+ * Neither key is ever routed through `resolveBapiCredentials`,
30
+ * `resolveCredentialBundle`, project configuration, or install state. A GitHub
31
+ * merge credential is not a Bridge credential and must not acquire Bridge's
32
+ * persistence.
33
+ *
34
+ * BAPI-1452: the local kind's canonical key is now `GITHUB_MERGE_TOKEN`. The
35
+ * pre-BAPI-1452 spelling survives ONLY as {@link LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY},
36
+ * a same-kind alias read after the canonical key. It is still never a fallback
37
+ * between kinds: the machine kind reads only `BAPI_MACHINE_MERGE_TOKEN`, which
38
+ * has no alias and is not deprecated.
39
+ */
40
+ export const LOCAL_MERGE_CREDENTIAL_ENV_KEYS = {
41
+ local: "GITHUB_MERGE_TOKEN",
42
+ machine_token: "BAPI_MACHINE_MERGE_TOKEN",
43
+ };
44
+ /**
45
+ * BAPI-1452: the deprecated, still-supported spelling of the LOCAL credential
46
+ * key. Read only when the canonical {@link LOCAL_MERGE_CREDENTIAL_ENV_KEYS}.local
47
+ * value is absent or blank — a non-empty canonical value that fails validation
48
+ * is NOT rescued by it. There is no removal date.
49
+ */
50
+ export const LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY = "BAPI_LOCAL_MERGE_TOKEN";
51
+ /**
52
+ * BAPI-1452: the shape a configured credential value must have to be used.
53
+ *
54
+ * Printable, non-whitespace ASCII of bounded length — every GitHub token format
55
+ * fits, and a value carrying an embedded space, a control character, or a pasted
56
+ * multi-line blob does not. Deliberately not a token-format check: a shape this
57
+ * loose can reject only values that cannot possibly authenticate.
58
+ */
59
+ const MERGE_CREDENTIAL_VALUE_PATTERN = /^[\x21-\x7E]{1,4096}$/;
60
+ /** Classify one raw environment value without exposing it. */
61
+ function classifyCredentialValue(raw) {
62
+ if (typeof raw !== "string")
63
+ return "absent";
64
+ const token = raw.trim();
65
+ if (token.length === 0)
66
+ return "absent";
67
+ return MERGE_CREDENTIAL_VALUE_PATTERN.test(token) ? token : "invalid";
68
+ }
69
+ /**
70
+ * Resolve one credential kind and report where it came from (BAPI-1452).
71
+ *
72
+ * Local kind: `GITHUB_MERGE_TOKEN` first, then the deprecated
73
+ * `BAPI_LOCAL_MERGE_TOKEN`. An absent or whitespace-only canonical value permits
74
+ * the alias; a non-empty canonical value that fails validation returns
75
+ * `invalid` and the alias is not consulted. Machine kind: only
76
+ * `BAPI_MACHINE_MERGE_TOKEN`. Never the other kind's key, never an inherited
77
+ * `GH_TOKEN`/`GITHUB_TOKEN`.
78
+ */
79
+ export function resolveLocalMergeCredentialDetailed(env, kind) {
80
+ const primaryKey = LOCAL_MERGE_CREDENTIAL_ENV_KEYS[kind];
81
+ const keys = kind === "local" ? [primaryKey, LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY] : [primaryKey];
82
+ for (const key of keys) {
83
+ const classified = classifyCredentialValue(env[key]);
84
+ if (classified === "absent")
85
+ continue;
86
+ if (classified === "invalid")
87
+ return { status: "invalid", source_key: key };
88
+ return {
89
+ status: "resolved",
90
+ credential: { kind, token: classified },
91
+ source_key: key,
92
+ via_alias: key === LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY,
93
+ };
94
+ }
95
+ return { status: "missing" };
96
+ }
97
+ /**
98
+ * Resolve the one credential a kind is allowed to read.
99
+ *
100
+ * Returns `null` for an absent or blank value — never the other kind's value and
101
+ * never an inherited one. BAPI-1452: also `null` for a value that fails
102
+ * validation, so no privileged subprocess ever runs with it; callers that must
103
+ * tell "invalid" from "missing" use {@link resolveLocalMergeCredentialDetailed}.
104
+ */
105
+ export function resolveLocalMergeCredential(env, kind) {
106
+ const resolution = resolveLocalMergeCredentialDetailed(env, kind);
107
+ return resolution.status === "resolved" ? resolution.credential : null;
108
+ }
109
+ /** BAPI-1461: whether a value has the shape a merge credential must have. */
110
+ export function isUsableMergeCredentialValue(raw) {
111
+ const classified = classifyCredentialValue(raw);
112
+ return classified !== "absent" && classified !== "invalid";
113
+ }
114
+ /** The `source_key` reported for a credential that came from the store. */
115
+ export const STORED_MERGE_CREDENTIAL_SOURCE_KEY = "store";
116
+ /**
117
+ * BAPI-1461: resolve one kind with the stored-entry fallback.
118
+ *
119
+ * The order for the local kind is fixed: `GITHUB_MERGE_TOKEN`, then
120
+ * `BAPI_LOCAL_MERGE_TOKEN`, then the stored entry. The synchronous resolver
121
+ * runs first and its answer wins whenever it resolves or reports an invalid
122
+ * value, so an invalid canonical value is never rescued by the alias or the
123
+ * store. `machine_token` returns the synchronous answer unchanged. The store is
124
+ * consulted only when `allowStoredEntry === true` and a reader was supplied.
125
+ * Nothing here reads `gh auth`, `GH_TOKEN`, `GITHUB_TOKEN`, or a Bridge key.
126
+ */
127
+ export async function resolveLocalMergeCredentialDetailedAsync(env, kind, options = {}) {
128
+ const sync = resolveLocalMergeCredentialDetailed(env, kind);
129
+ if (sync.status === "resolved") {
130
+ return {
131
+ status: "resolved",
132
+ credential: sync.credential,
133
+ source: sync.via_alias ? "alias" : "env",
134
+ source_key: sync.source_key,
135
+ via_alias: sync.via_alias,
136
+ };
137
+ }
138
+ if (sync.status === "invalid") {
139
+ return {
140
+ status: "invalid",
141
+ source: sync.source_key === LOCAL_MERGE_CREDENTIAL_ALIAS_ENV_KEY ? "alias" : "env",
142
+ source_key: sync.source_key,
143
+ };
144
+ }
145
+ if (kind !== "local" || options.allowStoredEntry !== true || !options.readStoredEntry) {
146
+ return { status: "missing" };
147
+ }
148
+ let stored;
149
+ try {
150
+ stored = await options.readStoredEntry();
151
+ }
152
+ catch {
153
+ return { status: "store-unavailable" };
154
+ }
155
+ if (stored.status === "unavailable")
156
+ return { status: "store-unavailable" };
157
+ if (stored.status === "absent")
158
+ return { status: "missing" };
159
+ const classified = classifyCredentialValue(stored.token);
160
+ if (classified === "absent")
161
+ return { status: "missing" };
162
+ if (classified === "invalid") {
163
+ return { status: "invalid", source: "store", source_key: STORED_MERGE_CREDENTIAL_SOURCE_KEY };
164
+ }
165
+ return {
166
+ status: "resolved",
167
+ credential: { kind, token: classified },
168
+ source: "store",
169
+ source_key: STORED_MERGE_CREDENTIAL_SOURCE_KEY,
170
+ via_alias: false,
171
+ };
172
+ }
@@ -42,7 +42,7 @@ import { MERGE_WORKFLOW_CHILD_MERGE_COMPATIBLE_VERSIONS, MERGE_WORKFLOW_CODEOWNE
42
42
  */
43
43
  export const MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION = "re-run once the Bridge API is reachable and repository credentials resolve; merge " +
44
44
  "eligibility is UNKNOWN here, not confirmed missing. If the server reports no " +
45
- "merge-execution block at all, upgrade the Bridge API deploy.";
45
+ "merge-execution block at all, email info@bridgegpt.ai.";
46
46
  /**
47
47
  * Remediation for a CONFIRMED insufficiency. Names the one command that fixes it.
48
48
  *
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Already-merged child detection and skip selection for `drive-epic` (BAPI-1510).
3
+ *
4
+ * Pure helpers plus one injected classification call. Nothing here prints, reads a
5
+ * file, or touches the network except through the injected `classify` function.
6
+ *
7
+ * The posture is conservative: only a current server verdict of `done` (completed
8
+ * delivery binding, confirmed merge commit, covered by the indexed cut) is ever
9
+ * skipped on its own. `probably_done` needs the operator's explicit `--skip-child`.
10
+ * Every uncertain, failed, or malformed result keeps the child in the plan.
11
+ */
12
+ /** The server's per-request bound on child keys. */
13
+ export const CHILD_MERGE_STATUS_BATCH_SIZE = 100;
14
+ /**
15
+ * Classify every plan child against one previously resolved cut, in batches of at
16
+ * most {@link CHILD_MERGE_STATUS_BATCH_SIZE}. Any failed or malformed batch fails
17
+ * the WHOLE check: a partial result must never feed a skip set.
18
+ */
19
+ export async function classifyPlanChildren(classify, epicKey, childKeys, cutCommitSha) {
20
+ const results = [];
21
+ try {
22
+ for (let i = 0; i < childKeys.length; i += CHILD_MERGE_STATUS_BATCH_SIZE) {
23
+ const batch = childKeys.slice(i, i + CHILD_MERGE_STATUS_BATCH_SIZE);
24
+ const response = await classify({ epicKey, childKeys: batch, cutCommitSha });
25
+ if (response.cut_commit_sha !== cutCommitSha ||
26
+ response.children.length !== batch.length ||
27
+ response.children.some((child, index) => child.ticket_key !== batch[index])) {
28
+ return { ok: false };
29
+ }
30
+ results.push(...response.children);
31
+ }
32
+ }
33
+ catch {
34
+ return { ok: false };
35
+ }
36
+ return { ok: true, results };
37
+ }
38
+ /**
39
+ * Decide the skip set from current verdicts.
40
+ *
41
+ * - Automatic: `done` and not named by `--keep-child`.
42
+ * - Explicit: `--skip-child`, honored only for `done` or `probably_done`.
43
+ * - `results === null` means the check could not run: no automatic skips, and every
44
+ * explicit skip is a refusal.
45
+ */
46
+ export function selectSkips(input) {
47
+ const planKeys = new Set(input.planKeys);
48
+ const keep = new Set(input.keepChildren);
49
+ const refusals = [];
50
+ const skipKeys = new Set();
51
+ const confirmedKeys = new Set();
52
+ for (const key of input.skipChildren) {
53
+ if (!planKeys.has(key))
54
+ refusals.push({ ticketKey: key, reason: "not_in_plan" });
55
+ }
56
+ if (input.results === null) {
57
+ for (const key of input.skipChildren) {
58
+ if (planKeys.has(key))
59
+ refusals.push({ ticketKey: key, reason: "check_unavailable" });
60
+ }
61
+ return { skipKeys, confirmedKeys, refusals };
62
+ }
63
+ const byKey = new Map(input.results.map((result) => [result.ticket_key, result]));
64
+ const explicit = new Set(input.skipChildren);
65
+ for (const key of input.planKeys) {
66
+ const verdict = byKey.get(key)?.classification;
67
+ if (explicit.has(key)) {
68
+ if (verdict === "done" || verdict === "probably_done") {
69
+ skipKeys.add(key);
70
+ confirmedKeys.add(key);
71
+ }
72
+ else {
73
+ refusals.push({ ticketKey: key, reason: "not_skippable" });
74
+ }
75
+ continue;
76
+ }
77
+ if (verdict === "done" && !keep.has(key))
78
+ skipKeys.add(key);
79
+ }
80
+ return { skipKeys, confirmedKeys, refusals };
81
+ }
82
+ /**
83
+ * The preview entries: every `done` and `probably_done` child, every `unknown`
84
+ * child, and every proven-merged child that merged after the index. A child that
85
+ * simply has no merged PR is left out. `will_skip` is true only for children in
86
+ * the skip set.
87
+ */
88
+ export function buildAlreadyDoneEntries(results, skipKeys) {
89
+ return results
90
+ .filter((result) => result.classification === "done" ||
91
+ result.classification === "probably_done" ||
92
+ result.classification === "unknown" ||
93
+ result.reason === "merged_after_index")
94
+ .map((result) => ({ ...result, will_skip: skipKeys.has(result.ticket_key) }));
95
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Immutable trimming of already-merged children out of an epic plan DAG (BAPI-1510).
3
+ *
4
+ * Pure: returns newly constructed data and never mutates the caller's plan, so the
5
+ * operator's plan sidecar stays byte-identical. A skipped node is removed together
6
+ * with every reference to it — `depends_on` entries and edges naming it on either
7
+ * end — so the trimmed plan has no dangling dependency and still passes server-side
8
+ * plan validation. Node order and retained edge order are preserved, and every other
9
+ * property (status, automations, touched files, unknown metadata) is carried over.
10
+ */
11
+ function cloneJson(value) {
12
+ return JSON.parse(JSON.stringify(value));
13
+ }
14
+ export function trimDonePlanNodes(plan, skipKeys) {
15
+ const trimmed = cloneJson(plan);
16
+ const nodes = [];
17
+ for (const node of trimmed.nodes ?? []) {
18
+ if (skipKeys.has(node.ticket_key))
19
+ continue;
20
+ if (Array.isArray(node.depends_on)) {
21
+ node.depends_on = node.depends_on.filter((key) => !skipKeys.has(key));
22
+ }
23
+ nodes.push(node);
24
+ }
25
+ trimmed.nodes = nodes;
26
+ if (Array.isArray(trimmed.edges)) {
27
+ trimmed.edges = trimmed.edges.filter((edge) => !skipKeys.has(edge.from) && !skipKeys.has(edge.to));
28
+ }
29
+ return trimmed;
30
+ }