@bridge_gpt/mcp-server 0.2.69 → 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 (151) hide show
  1. package/README.md +140 -292
  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 +23 -4
  7. package/build/claude-review-workflow-drift.js +3 -3
  8. package/build/codex-bridge-profile.js +1 -1
  9. package/build/commands.generated.js +6 -6
  10. package/build/conductor/bridge-api-client.js +598 -19
  11. package/build/conductor/bring-up-facts.js +9 -9
  12. package/build/conductor/doctor.js +466 -18
  13. package/build/conductor/epic-child-continuation.js +54 -3
  14. package/build/conductor/epic-implementer-cli.js +132 -34
  15. package/build/conductor/epic-runtime.js +2 -2
  16. package/build/conductor/epic-state.js +1 -1
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/install-doctor.js +4 -2
  19. package/build/conductor/launch-need-probes.js +337 -0
  20. package/build/conductor/local-merge.js +241 -56
  21. package/build/conductor/merge-credential.js +172 -0
  22. package/build/conductor/merge-execution-eligibility.js +1 -1
  23. package/build/conductor/merged-child-check.js +95 -0
  24. package/build/conductor/plan-trim.js +30 -0
  25. package/build/conductor/pr-ci-producer.js +1 -1
  26. package/build/conductor/readiness-cli.js +11 -6
  27. package/build/conductor/readiness-prerequisites.js +41 -0
  28. package/build/conductor/readiness-sections.js +345 -55
  29. package/build/conductor/readiness.js +99 -87
  30. package/build/conductor/recovery-cli.js +606 -21
  31. package/build/conductor/recovery-operations.js +306 -7
  32. package/build/conductor/repair-contract.js +47 -2
  33. package/build/conductor/repository-workflow-remedy.js +8 -6
  34. package/build/conductor/stored-merge-credential.js +76 -0
  35. package/build/conductor/stored-merge-token-probe.js +28 -0
  36. package/build/conductor/temporary-plan.js +50 -0
  37. package/build/conductor/wait-run-cli.js +759 -0
  38. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  39. package/build/conductor-bin.js +2 -2
  40. package/build/conductor-claude-hook-bin.js +2 -2
  41. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  42. package/build/conductor-removed-stub-bin.js +2 -5
  43. package/build/credential-store.js +356 -13
  44. package/build/docs.generated.js +26 -13
  45. package/build/doctor.js +94 -32
  46. package/build/drive-epic-help.js +212 -0
  47. package/build/drive-epic.js +1840 -373
  48. package/build/epic-ci-workflow-drift.js +77 -0
  49. package/build/epic-ci-workflow-install.js +183 -0
  50. package/build/epic-ci-workflow.js +157 -0
  51. package/build/epic-contract-context.js +326 -0
  52. package/build/epic-implementer-bin.js +57 -41
  53. package/build/epic-implementer-claude-hook-bin.js +2 -2
  54. package/build/escalation-reachability.js +134 -0
  55. package/build/executor/cleanup-report.js +99 -0
  56. package/build/executor/cli.js +12 -1
  57. package/build/executor/completion-contract.js +66 -11
  58. package/build/executor/completion-supervision.js +99 -0
  59. package/build/executor/delivery-outcome.js +179 -0
  60. package/build/executor/deps.js +3 -0
  61. package/build/executor/env.js +71 -0
  62. package/build/executor/failure-reason-codes.js +126 -0
  63. package/build/executor/heartbeat.js +38 -0
  64. package/build/executor/http-client.js +72 -0
  65. package/build/executor/integration-job.js +298 -5
  66. package/build/executor/job-errors.js +36 -6
  67. package/build/executor/job-runner.js +720 -36
  68. package/build/executor/likely-tests.js +36 -2
  69. package/build/executor/merge-job.js +145 -2
  70. package/build/executor/observation.js +73 -4
  71. package/build/executor/permissions.js +193 -97
  72. package/build/executor/preflight.js +14 -3
  73. package/build/executor/process-containment.js +28 -2
  74. package/build/executor/prompt-spec.js +50 -5
  75. package/build/executor/rebase-finalization.js +38 -3
  76. package/build/executor/reconcile-overlap-job.js +35 -7
  77. package/build/executor/required-work-ledger.js +236 -81
  78. package/build/executor/resume-pre-spawn.js +38 -1
  79. package/build/executor/runner.js +91 -3
  80. package/build/executor/test-clock.js +8 -0
  81. package/build/executor/types.js +37 -2
  82. package/build/executor/worker-cli-launcher.js +259 -0
  83. package/build/executor/worker-command.js +41 -4
  84. package/build/executor/worker-database-target.js +91 -0
  85. package/build/executor/worker-database.js +908 -0
  86. package/build/executor/worker-finalization.js +106 -0
  87. package/build/executor/worker-guard-hook.js +203 -0
  88. package/build/executor/worker-resources.js +47 -0
  89. package/build/executor/worker-verification-guidance.js +15 -2
  90. package/build/executor/worktree.js +303 -15
  91. package/build/executor-launch.js +11 -1
  92. package/build/generated-assets-doctor.js +9 -1
  93. package/build/generated-assets-health.js +13 -4
  94. package/build/generated-assets-legacy.js +39 -0
  95. package/build/generated-assets-paths.js +12 -1
  96. package/build/generated-assets-repair.js +4 -1
  97. package/build/generated-assets-test-support.js +12 -0
  98. package/build/github-remote.js +33 -0
  99. package/build/github-sign-in.js +62 -0
  100. package/build/index.js +1067 -536
  101. package/build/init.js +39 -1
  102. package/build/install-bridge-conductor-gate.js +78 -0
  103. package/build/install-bridge-conductor-setup.js +413 -0
  104. package/build/install-bridge-conductor.js +1226 -267
  105. package/build/install-bridge.js +376 -60
  106. package/build/mcp-install-state.js +8 -2
  107. package/build/merge-pull-request.js +6 -0
  108. package/build/merge-workflow-install.js +763 -345
  109. package/build/phase-result-artifacts.js +75 -20
  110. package/build/pipeline-orchestrator.js +247 -4
  111. package/build/pipelines.generated.js +35 -35
  112. package/build/plan-discovery.js +15 -2
  113. package/build/plan-execution-ledger.js +306 -17
  114. package/build/plan-phase-routing.js +48 -13
  115. package/build/plane/cli.js +105 -51
  116. package/build/plane/defaults.js +3 -0
  117. package/build/plane/plane-freshness.js +294 -0
  118. package/build/plane/schema-compatibility.js +137 -0
  119. package/build/plane/shutdown-deps.js +38 -0
  120. package/build/plane/shutdown.js +83 -8
  121. package/build/plane/terminal-run-cleanup.js +303 -0
  122. package/build/printed-remedy-commands.js +26 -4
  123. package/build/readiness-check.js +6 -2
  124. package/build/readiness-prerequisites.generated.js +532 -0
  125. package/build/readme.generated.js +1 -1
  126. package/build/setup-epic.js +400 -30
  127. package/build/shared-resources.generated.js +5 -0
  128. package/build/start-tickets.js +28 -15
  129. package/build/test-command-detection.js +247 -0
  130. package/build/test-command-validation.js +73 -0
  131. package/build/testing-config-client.js +150 -0
  132. package/build/ticket-delivery-artifacts.js +258 -0
  133. package/build/ticket-delivery-enrollment.js +306 -0
  134. package/build/ticket-delivery.js +205 -0
  135. package/build/ticket-review-summary.js +14 -0
  136. package/build/upgrade-cli.js +13 -0
  137. package/build/v2-readiness-requirements.js +552 -0
  138. package/build/version.generated.js +3 -3
  139. package/build/worker-guard-hook-bin.js +2 -2
  140. package/build/worktree-core.js +22 -7
  141. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  142. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  143. package/docs/EPIC-IMPLEMENTER.md +104 -63
  144. package/docs/epic-implementer/getting-started.md +131 -0
  145. package/docs/epic-implementer/self-hosting.md +133 -0
  146. package/package.json +3 -3
  147. package/pipelines/idea-to-ticket.json +1 -1
  148. package/pipelines/implement-ticket.json +8 -8
  149. package/pipelines/review-ticket.json +2 -2
  150. package/design-assets/favicon/favicon-32x32.png +0 -0
  151. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -18,11 +18,12 @@
18
18
  * callers: `recovery-cli.ts` and `plane/shutdown.ts`.
19
19
  */
20
20
  import { randomUUID } from "crypto";
21
- import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, RERUN_CI_ROUTE_UNAVAILABLE_CODE, RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE, rerunCi, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, postGrantContinuation, resumeCiTicket, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
21
+ import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, RERUN_CI_ROUTE_UNAVAILABLE_CODE, RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE, rerunCi, resolveHandoff, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, postGrantContinuation, resumeCiTicket, regeneratePlanTicket, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
22
22
  import { clearGuidedReference, guardForBinding, GUIDED_PAYLOAD_CONFLICT_MESSAGE, GUIDED_REFERENCE_STORE_FAILED_MESSAGE, guidedPayloadDigest, prepareGuidedRequest, } from "./guided-repair.js";
23
23
  import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
24
- import { canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
24
+ import { bindingsDriverContextSha256, CONTINUATION_CI_WAITING_REASONS, canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, driverContextSha256, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
25
25
  import { resolveLocalMergeCredential, resolveLocalMergeMethod, runApprovedLocalMerge, } from "./local-merge.js";
26
+ import { resolveMergeCredentialAtAction, } from "./stored-merge-credential.js";
26
27
  import { RERUN_CI_UNCONFIRMED_REASON, RESUME_RUN_UNCONFIRMED_MESSAGE } from "./repair-contract.js";
27
28
  /**
28
29
  * Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
@@ -296,6 +297,19 @@ export async function scopeReparseRepair(access, options, deps = {}) {
296
297
  return classifyRepairError(err, "scope-reparse request failed");
297
298
  }
298
299
  }
300
+ /**
301
+ * BAPI-1494 — record the owner's decision on a ticket's verification hand-offs.
302
+ * The server records the verified request actor and decides every refusal.
303
+ */
304
+ export async function resolveHandoffRepair(access, options, deps = {}) {
305
+ try {
306
+ const call = deps.resolveHandoff ?? resolveHandoff;
307
+ return { ok: true, result: await call(access, options) };
308
+ }
309
+ catch (err) {
310
+ return classifyRepairError(err, "resolve-handoff request failed");
311
+ }
312
+ }
299
313
  /**
300
314
  * Dispatch a remediation job with driver context. `context` must already be
301
315
  * validated against `DRIVER_CONTEXT_MAX_BYTES`; the server selects the job type.
@@ -341,6 +355,17 @@ export async function mergeChildRepair(access, options, deps = {}) {
341
355
  return { ok: true, result: serverResult };
342
356
  }
343
357
  const runLocal = deps.runLocalMerge ?? runApprovedLocalMerge;
358
+ const env = deps.env ?? process.env;
359
+ // BAPI-1461: the repository identity is resolved before any store lookup, and
360
+ // an unresolved or conflicting identity reads nothing.
361
+ const resolution = deps.storedMergeCredential
362
+ ? await resolveMergeCredentialAtAction(env, "local", deps.storedMergeCredential)
363
+ : null;
364
+ const credential = resolution === null
365
+ ? resolveLocalMergeCredential(env, "local")
366
+ : resolution.status === "resolved"
367
+ ? resolution.credential
368
+ : null;
344
369
  let succeeded = false;
345
370
  let alreadyMerged = false;
346
371
  let failureReason = "gh_merge_failed";
@@ -357,10 +382,7 @@ export async function mergeChildRepair(access, options, deps = {}) {
357
382
  }, { method: resolveLocalMergeMethod(local.merge_method) },
358
383
  // BAPI-1136: an explicitly configured local credential, never the ambient
359
384
  // login. `env` remains the source of the allowlisted operational base only.
360
- {
361
- env: deps.env ?? process.env,
362
- credential: resolveLocalMergeCredential(deps.env ?? process.env, "local"),
363
- });
385
+ { env, credential });
364
386
  succeeded = localResult.status === "succeeded";
365
387
  alreadyMerged = succeeded && localResult.reason === "already_merged";
366
388
  failureReason = boundedLocalMergeReason(localResult.reason);
@@ -632,6 +654,22 @@ export async function continueChildOnce(access, options, deps = {}) {
632
654
  reference = null;
633
655
  }
634
656
  const reusedReference = reference !== null;
657
+ // BAPI-1514 — a reused request is bound to the context it was first sent with
658
+ // (by hash). Re-sending it with a different context, or with/without one, would
659
+ // be a different request under the same id; refuse locally, sending nothing.
660
+ if (reference !== null) {
661
+ const storedSha = bindingsDriverContextSha256(reference.bindings);
662
+ const wantedSha = options.driverContext === undefined ? undefined : driverContextSha256(options.driverContext);
663
+ if (storedSha !== wantedSha) {
664
+ return {
665
+ kind: "unavailable",
666
+ message: `a pending continuation request (${reference.requestId}) was sent ` +
667
+ (storedSha === undefined ? "without --context-file" : "with a different --context-file") +
668
+ "; re-run with the same --context-file (or none, as before) to settle that same " +
669
+ "request, or look it up with continuation-receipt",
670
+ };
671
+ }
672
+ }
635
673
  if (reference === null) {
636
674
  let status;
637
675
  try {
@@ -662,6 +700,7 @@ export async function continueChildOnce(access, options, deps = {}) {
662
700
  expectedRowVersion: current.row_version,
663
701
  expectedHeadSha: head,
664
702
  sourceParkGeneration: current.park_generation,
703
+ ...(options.driverContext !== undefined ? { driverContext: options.driverContext } : {}),
665
704
  };
666
705
  reference = {
667
706
  version: 1,
@@ -699,9 +738,15 @@ export async function continueChildOnce(access, options, deps = {}) {
699
738
  requestFingerprint: ref.requestFingerprint,
700
739
  reason: options.reason,
701
740
  workerExitProof: options.workerExitProof,
741
+ ...(options.driverContext !== undefined ? { driverContext: options.driverContext } : {}),
702
742
  });
703
743
  if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
704
- await clear();
744
+ const keepForRetry = outcome.result.outcome === "refused" &&
745
+ outcome.result.retryable &&
746
+ outcome.result.reason_code !== null &&
747
+ CONTINUATION_CI_WAITING_REASONS.has(outcome.result.reason_code);
748
+ if (!keepForRetry)
749
+ await clear();
705
750
  return { kind: "decided", requestId: ref.requestId, reusedReference, result: outcome.result };
706
751
  }
707
752
  if (outcome.kind === "refused_http") {
@@ -711,6 +756,7 @@ export async function continueChildOnce(access, options, deps = {}) {
711
756
  requestId: ref.requestId,
712
757
  status: outcome.status,
713
758
  errorCode: outcome.errorCode,
759
+ ...(options.driverContext !== undefined ? { driverContextSent: true } : {}),
714
760
  };
715
761
  }
716
762
  // Uncertain (transport, malformed 2xx, 5xx, or the server's own unconfirmed).
@@ -1131,3 +1177,256 @@ export async function resumeCiOnce(access, options, deps = {}) {
1131
1177
  : "indeterminate";
1132
1178
  return { kind: "unconfirmed", transport, reusedReference, attempts: RESUME_CI_POST_LIMIT };
1133
1179
  }
1180
+ // ---------------------------------------------------------------------------
1181
+ // BAPI-1532 — `regenerate-plan`: a pinned, bounded answer to a named plan failure
1182
+ // ---------------------------------------------------------------------------
1183
+ /** The recovery-reference namespace of pinned `regenerate-plan` operations. */
1184
+ export const REGENERATE_PLAN_REFERENCE_NAMESPACE = "epic-plan-regenerations";
1185
+ /**
1186
+ * Bounded POSTs per invocation. Every POST of one invocation carries the SAME
1187
+ * operation key, route, reason, and park generation; only the row version may be
1188
+ * refreshed, and only while the park identity still matches.
1189
+ */
1190
+ export const REGENERATE_PLAN_POST_LIMIT = 3;
1191
+ const REGENERATE_PLAN_RETRY_DELAY_MS = 250;
1192
+ const REGENERATE_PLAN_OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
1193
+ const FINALIZATION_CAUSE = "worker_finalization_missing_branch_and_pr";
1194
+ /** The longest driver reason the request carries; the server bounds it again. */
1195
+ export const REGENERATE_PLAN_REASON_MAX_CHARS = 500;
1196
+ /**
1197
+ * Capture the park identity a `regenerate-plan` is pinned to, from authenticated
1198
+ * ticket state, mirroring the server's selection: the ticket is `needs_human`,
1199
+ * its single parked gate is `implement`, and either that gate holds a `parked`
1200
+ * `plan_failure` record for its own park generation, or its committed cause is the
1201
+ * BAPI-1487 finalization park (which the server admits through its own predicate
1202
+ * and which names no planner reason). Never derived from the park's reason text.
1203
+ */
1204
+ export function captureRegeneratePlanSubject(ticket) {
1205
+ if (ticket.status !== "needs_human")
1206
+ return { ok: false, reason: "not_parked" };
1207
+ const gates = isRecordValue(ticket.gates) ? ticket.gates : {};
1208
+ const parked = Object.entries(gates).filter(([, state]) => isRecordValue(state) && state.status === "needs_human");
1209
+ if (parked.length !== 1 || parked[0][0] !== "implement") {
1210
+ return { ok: false, reason: "not_implement_park" };
1211
+ }
1212
+ const gate = parked[0][1];
1213
+ const generation = isNonNegativeInteger(gate.park_generation) ? gate.park_generation : 0;
1214
+ const record = gate.plan_failure;
1215
+ if (isRecordValue(record)) {
1216
+ if (record.phase !== "parked")
1217
+ return { ok: false, reason: "park_superseded" };
1218
+ if (!isNonNegativeInteger(record.park_generation) || record.park_generation !== generation) {
1219
+ return { ok: false, reason: "park_superseded" };
1220
+ }
1221
+ return { ok: true, parkGeneration: generation };
1222
+ }
1223
+ if (gate.terminal_failure_cause === FINALIZATION_CAUSE) {
1224
+ return { ok: true, parkGeneration: generation };
1225
+ }
1226
+ return { ok: false, reason: "no_plan_failure" };
1227
+ }
1228
+ /** The fingerprint of a pinned operation's identity (the row version excluded). */
1229
+ export function computeRegeneratePlanFingerprint(ref) {
1230
+ return canonicalSha256({
1231
+ v: 1,
1232
+ operation: "regenerate_plan",
1233
+ target: { repo_name: ref.repoName, epic_run_id: ref.epicRunId, ticket_key: ref.ticketKey },
1234
+ route: ref.route,
1235
+ reason: ref.reason,
1236
+ park_generation: ref.parkGeneration,
1237
+ operation_key: ref.operationKey,
1238
+ });
1239
+ }
1240
+ function parseRegeneratePlanReference(raw) {
1241
+ if (!isRecordValue(raw) || raw.version !== 1 || raw.operation !== "regenerate_plan")
1242
+ return null;
1243
+ if (typeof raw.repoName !== "string" || !raw.repoName ||
1244
+ typeof raw.epicRunId !== "string" || !raw.epicRunId ||
1245
+ typeof raw.ticketKey !== "string" || !raw.ticketKey ||
1246
+ (raw.route !== "test" && raw.route !== "implementation") ||
1247
+ typeof raw.reason !== "string" || !raw.reason ||
1248
+ !isNonNegativeInteger(raw.parkGeneration) ||
1249
+ !isNonNegativeInteger(raw.expectedRowVersion) ||
1250
+ typeof raw.operationKey !== "string" ||
1251
+ !REGENERATE_PLAN_OPERATION_KEY_PATTERN.test(raw.operationKey) ||
1252
+ typeof raw.fingerprint !== "string") {
1253
+ return null;
1254
+ }
1255
+ const ref = {
1256
+ version: 1,
1257
+ operation: "regenerate_plan",
1258
+ repoName: raw.repoName,
1259
+ epicRunId: raw.epicRunId,
1260
+ ticketKey: raw.ticketKey,
1261
+ route: raw.route,
1262
+ reason: raw.reason,
1263
+ parkGeneration: raw.parkGeneration,
1264
+ expectedRowVersion: raw.expectedRowVersion,
1265
+ operationKey: raw.operationKey,
1266
+ fingerprint: raw.fingerprint,
1267
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
1268
+ };
1269
+ // A reference whose fingerprint no longer matches its own identity was
1270
+ // tampered with or truncated; it never authorizes a replay.
1271
+ return computeRegeneratePlanFingerprint(ref) === ref.fingerprint ? ref : null;
1272
+ }
1273
+ /**
1274
+ * Answer a named plan failure with one forced regeneration, its operation pinned.
1275
+ *
1276
+ * A pinned reference for this ticket is REUSED as-is — same operation key, same
1277
+ * route, same park generation — so a lost response or a restarted process is
1278
+ * answered from the server's durable record, and a later park is never silently
1279
+ * targeted. Only when no reference exists is the park identity read (once) from
1280
+ * authenticated ticket state, and the reference is stored BEFORE the first POST.
1281
+ *
1282
+ * At most {@link REGENERATE_PLAN_POST_LIMIT} POSTs, all with the same key, route
1283
+ * and park generation. A row-version conflict refreshes the row version ONLY while
1284
+ * the park identity still matches; otherwise the result is `stale_subject` and
1285
+ * nothing more is sent. There is no fallback to unpark or any other mutation.
1286
+ */
1287
+ export async function regeneratePlanOnce(access, options, deps = {}) {
1288
+ const fetchState = deps.fetchState ?? fetchEpicRunState;
1289
+ const post = deps.post ?? regeneratePlanTicket;
1290
+ const store = deps.store ?? {};
1291
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1292
+ const callerKey = { epicRunId: options.epicRunId, ticketKey: options.ticketKey };
1293
+ let reference = parseRegeneratePlanReference(await readRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, store));
1294
+ if (reference !== null && reference.repoName !== access.repoName)
1295
+ reference = null;
1296
+ const reusedReference = reference !== null;
1297
+ if (reference !== null && reference.route !== options.route) {
1298
+ // An earlier operation is pinned on a different route and may be unconfirmed.
1299
+ // Sending a second route could answer the same park twice, so nothing is sent.
1300
+ return { kind: "pinned_mismatch", pinnedRoute: reference.route };
1301
+ }
1302
+ const persist = async (ref) => {
1303
+ await writeRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, ref, store);
1304
+ if (ref.epicRunId !== callerKey.epicRunId || ref.ticketKey !== callerKey.ticketKey) {
1305
+ await writeRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, ref, store);
1306
+ }
1307
+ };
1308
+ const clear = async (ref) => {
1309
+ await clearRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, store);
1310
+ await clearRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, store);
1311
+ };
1312
+ const readTicket = async () => {
1313
+ try {
1314
+ const state = await fetchState(access, options.epicRunId);
1315
+ return state.ticket_statuses.find((t) => t.ticket_key === options.ticketKey) ?? null;
1316
+ }
1317
+ catch {
1318
+ return "unavailable";
1319
+ }
1320
+ };
1321
+ if (reference === null) {
1322
+ const ticket = await readTicket();
1323
+ if (ticket === "unavailable") {
1324
+ return { kind: "unavailable", message: "could not read the ticket's state, so nothing was sent" };
1325
+ }
1326
+ if (ticket === null)
1327
+ return { kind: "not_eligible", reason: "ticket_not_found" };
1328
+ const captured = captureRegeneratePlanSubject(ticket);
1329
+ if (!captured.ok)
1330
+ return { kind: "not_eligible", reason: captured.reason };
1331
+ const identity = {
1332
+ repoName: access.repoName,
1333
+ epicRunId: ticket.epic_run_id,
1334
+ ticketKey: ticket.ticket_key,
1335
+ route: options.route,
1336
+ reason: options.reason.slice(0, REGENERATE_PLAN_REASON_MAX_CHARS),
1337
+ parkGeneration: captured.parkGeneration,
1338
+ operationKey: (deps.newOperationKey ?? randomUUID)(),
1339
+ };
1340
+ reference = {
1341
+ version: 1,
1342
+ operation: "regenerate_plan",
1343
+ ...identity,
1344
+ expectedRowVersion: ticket.row_version,
1345
+ fingerprint: computeRegeneratePlanFingerprint(identity),
1346
+ createdAt: (deps.now ?? (() => new Date()))().toISOString(),
1347
+ };
1348
+ try {
1349
+ await persist(reference);
1350
+ }
1351
+ catch {
1352
+ return {
1353
+ kind: "unavailable",
1354
+ message: "could not store the local recovery reference, so the request was not sent",
1355
+ };
1356
+ }
1357
+ }
1358
+ let ref = reference;
1359
+ let last = null;
1360
+ for (let attempt = 1; attempt <= REGENERATE_PLAN_POST_LIMIT; attempt += 1) {
1361
+ if (attempt > 1)
1362
+ await sleep(REGENERATE_PLAN_RETRY_DELAY_MS * (attempt - 1));
1363
+ const outcome = await post(access, {
1364
+ epicRunId: ref.epicRunId,
1365
+ ticketKey: ref.ticketKey,
1366
+ route: ref.route,
1367
+ reason: ref.reason,
1368
+ expectedRowVersion: ref.expectedRowVersion,
1369
+ parkGeneration: ref.parkGeneration,
1370
+ operationKey: ref.operationKey,
1371
+ });
1372
+ last = outcome;
1373
+ if (outcome.kind === "result") {
1374
+ await clear(ref);
1375
+ return { kind: outcome.result.outcome, result: outcome.result, reusedReference };
1376
+ }
1377
+ if (outcome.kind === "unsupported_server") {
1378
+ // Nothing was written, and no other mutation stands in for this one.
1379
+ await clear(ref);
1380
+ return { kind: "unsupported_server", status: outcome.status };
1381
+ }
1382
+ if (outcome.kind === "uncertain")
1383
+ continue;
1384
+ if (outcome.kind === "refused" && outcome.errorCode === "PLAN_REGEN_BUSY")
1385
+ continue;
1386
+ if (outcome.kind === "refused" && outcome.errorCode === "CONFLICT") {
1387
+ // Refresh the row version ONLY while the park identity is the same one.
1388
+ const ticket = await readTicket();
1389
+ if (ticket === "unavailable" || ticket === null) {
1390
+ return { kind: "refused", errorCode: outcome.errorCode, status: outcome.status, reusedReference };
1391
+ }
1392
+ const current = captureRegeneratePlanSubject(ticket);
1393
+ if (!current.ok || current.parkGeneration !== ref.parkGeneration) {
1394
+ return { kind: "stale_subject", reusedReference };
1395
+ }
1396
+ ref = { ...ref, expectedRowVersion: ticket.row_version };
1397
+ try {
1398
+ await persist(ref);
1399
+ }
1400
+ catch {
1401
+ return {
1402
+ kind: "unavailable",
1403
+ message: "could not update the local recovery reference, so the retry was not sent",
1404
+ };
1405
+ }
1406
+ continue;
1407
+ }
1408
+ // A stale park is the subject changing: back to explain_run, nothing written.
1409
+ if (outcome.kind === "refused" && outcome.errorCode === "PLAN_REGEN_STALE_PARK") {
1410
+ await clear(ref);
1411
+ return { kind: "stale_subject", reusedReference };
1412
+ }
1413
+ // Every other refusal or rejection is authoritative: nothing was written,
1414
+ // and this operation is finished.
1415
+ await clear(ref);
1416
+ return {
1417
+ kind: "refused",
1418
+ errorCode: outcome.kind === "refused" ? outcome.errorCode : outcome.errorCode ?? "REQUEST_REJECTED",
1419
+ status: outcome.status,
1420
+ reusedReference,
1421
+ };
1422
+ }
1423
+ if (last !== null && last.kind === "refused") {
1424
+ // Busy or row-version churn for the whole budget: nothing was written, and
1425
+ // the pinned reference is kept for the next invocation.
1426
+ return { kind: "refused", errorCode: last.errorCode, status: last.status, reusedReference };
1427
+ }
1428
+ const transport = last !== null && last.kind === "uncertain"
1429
+ ? last.status !== undefined ? `${last.reason} (status ${last.status})` : last.reason
1430
+ : "indeterminate";
1431
+ return { kind: "unconfirmed", transport, reusedReference, attempts: REGENERATE_PLAN_POST_LIMIT };
1432
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The shared contract for the eight operator repair verbs: BAPI-1154's
2
+ * The shared contract for the nine operator repair verbs: BAPI-1154's
3
3
  * `scope-reparse`, `dispatch-remediation`, and `merge-child`, BAPI-1176's
4
4
  * `scope-catch-up`, `patch-policy`, and `resume-run`, BAPI-1326's
5
- * ticket-addressed `resume-ci`, and BAPI-1388's ticket-addressed `rerun-ci`.
5
+ * ticket-addressed `resume-ci`, BAPI-1388's ticket-addressed `rerun-ci`, and
6
+ * BAPI-1494's ticket-addressed `resolve-handoff`.
6
7
  *
7
8
  * CLI-only, exactly like the BAPI-872 recovery verbs: none of these names is ever
8
9
  * registered as an MCP tool (`index-static.test.ts` pins that). This module is the
@@ -23,6 +24,7 @@ export const VERB_PATCH_POLICY = "patch-policy";
23
24
  export const VERB_RESUME_RUN = "resume-run";
24
25
  export const VERB_RESUME_CI = "resume-ci";
25
26
  export const VERB_RERUN_CI = "rerun-ci";
27
+ export const VERB_RESOLVE_HANDOFF = "resolve-handoff";
26
28
  /** Every repair verb, in the order an operator meets them. */
27
29
  export const OPERATOR_REPAIR_VERBS = [
28
30
  VERB_SCOPE_REPARSE,
@@ -33,6 +35,7 @@ export const OPERATOR_REPAIR_VERBS = [
33
35
  VERB_RESUME_RUN,
34
36
  VERB_RESUME_CI,
35
37
  VERB_RERUN_CI,
38
+ VERB_RESOLVE_HANDOFF,
36
39
  ];
37
40
  /**
38
41
  * The ONE cap on driver context, in UTF-8 BYTES — the same number the server
@@ -113,6 +116,14 @@ export const OPERATOR_REPAIR_REFUSAL_CODES = [
113
116
  // BAPI-1333
114
117
  "GUIDED_REPAIR_DISABLED",
115
118
  "REPAIR_SUBJECT_STALE",
119
+ // BAPI-1494
120
+ "HANDOFF_NOT_PENDING",
121
+ "HANDOFF_ALREADY_RESOLVED",
122
+ "RESOLUTION_ACTOR_UNAVAILABLE",
123
+ "EPIC_HEAD_UNREADABLE",
124
+ "HANDOFF_LEDGER_UNREADABLE",
125
+ "HANDOFF_RESOLUTION_CAPACITY",
126
+ "HANDOFF_SELECTION_CHANGED",
116
127
  ];
117
128
  /** True when `code` is one of the closed repair refusal codes. */
118
129
  export function isOperatorRepairRefusalCode(code) {
@@ -207,3 +218,37 @@ export const RERUN_CI_NOT_RUNNABLE_REASONS = [
207
218
  ];
208
219
  /** The closed overall outcomes of an applied `rerun-ci`. */
209
220
  export const RERUN_CI_OUTCOMES = ["enqueued", "partial", "nothing_to_enqueue"];
221
+ // ---------------------------------------------------------------------------
222
+ // BAPI-1494 — `resolve-handoff`
223
+ // ---------------------------------------------------------------------------
224
+ /**
225
+ * The closed `resolve-handoff` refusal subset — mirrors
226
+ * `RESOLVE_HANDOFF_REFUSAL_CODES` in `operator_repair_contract.py`.
227
+ */
228
+ export const RESOLVE_HANDOFF_REFUSAL_CODES = [
229
+ "RESOLUTION_ACTOR_UNAVAILABLE",
230
+ "TICKET_NOT_IN_RUN",
231
+ "EPIC_HEAD_UNREADABLE",
232
+ "HANDOFF_LEDGER_UNREADABLE",
233
+ "HANDOFF_NOT_PENDING",
234
+ "HANDOFF_ALREADY_RESOLVED",
235
+ "HANDOFF_RESOLUTION_CAPACITY",
236
+ "HANDOFF_SELECTION_CHANGED",
237
+ "ROW_VERSION_CONFLICT",
238
+ ];
239
+ /** True when `code` is one of the closed `resolve-handoff` refusal codes. */
240
+ export function isResolveHandoffRefusalCode(code) {
241
+ return (typeof code === "string" &&
242
+ RESOLVE_HANDOFF_REFUSAL_CODES.includes(code));
243
+ }
244
+ /** The two decision kinds. `risk_accepted` is never shown as verified or passed. */
245
+ export const HANDOFF_RESOLUTION_KINDS = ["verified", "risk_accepted"];
246
+ /** The display label for each kind — the same labels the server uses. */
247
+ export const HANDOFF_RESOLUTION_KIND_LABELS = {
248
+ verified: "verified",
249
+ risk_accepted: "risk accepted",
250
+ };
251
+ /** Bounds shared with the server (`HANDOFF_RESOLUTION_*` in `api/models/epic_run.py`). */
252
+ export const HANDOFF_RESOLUTION_MAX_STEPS = 50;
253
+ export const HANDOFF_RESOLUTION_EVIDENCE_REF_MAX_CHARS = 500;
254
+ export const HANDOFF_RESOLUTION_REASON_MAX_CHARS = 2000;
@@ -1,3 +1,4 @@
1
+ import { CONSUMER_GUIDE_POINTER } from "../printed-remedy-commands.js";
1
2
  /** The two fixed workflow files unattended auto-merge reads. Named once here. */
2
3
  export const REVIEW_WORKFLOW_PATH = ".github/workflows/claude-review.yml";
3
4
  export const CI_WORKFLOW_PATH = ".github/workflows/epic-implementer-ci.yml";
@@ -5,8 +6,9 @@ export const CI_WORKFLOW_PATH = ".github/workflows/epic-implementer-ci.yml";
5
6
  export const MIGRATION_GUARD_JOB_ID = "migration-guard";
6
7
  export const MIGRATION_GUARD_CHECKER_PATH = "scripts/check_alembic_migration_graph.py";
7
8
  export const MIGRATION_GUARD_INVOCATION = `python ${MIGRATION_GUARD_CHECKER_PATH}`;
8
- const RETRY = "Run the readiness check again, or pass --attended. " +
9
- "If it keeps failing, contact Bridge operations.";
9
+ const RETRY = "Retry readiness, or use --attended.";
10
+ /** Every workflow remedy ends with the guide a consumer can actually install. */
11
+ const GUIDE_SENTENCE = `See ${CONSUMER_GUIDE_POINTER}.`;
10
12
  const TEMPLATES = {
11
13
  review_workflow_file: `${REVIEW_WORKFLOW_PATH} is missing from the default branch. Add it, or pass --attended.`,
12
14
  review_run_id_marker: `${REVIEW_WORKFLOW_PATH} does not emit the run-id: marker in its verdict comment. ` +
@@ -29,7 +31,7 @@ const TEMPLATES = {
29
31
  "Add that job, or pass --attended.",
30
32
  };
31
33
  /** Shown when the report is missing, incomplete, or not in a shape we can name. */
32
- const UNVERIFIABLE_FALLBACK = "Bridge could not verify the review and CI workflows on the default branch. " + RETRY;
34
+ const UNVERIFIABLE_FALLBACK = `Bridge could not verify the review and CI workflows on the default branch. ${RETRY} ${GUIDE_SENTENCE}`;
33
35
  function classifyReview(w) {
34
36
  if (w === null || w === undefined || typeof w !== "object")
35
37
  return "malformed";
@@ -86,7 +88,7 @@ function classifyCi(c) {
86
88
  * unreadable, and otherwise the empty result below.
87
89
  */
88
90
  export function describeRepositoryWorkflowRemedy(readiness) {
89
- const review = classifyReview(readiness?.review_workflow);
91
+ const review = readiness?.epic_branch_ci?.effective_review_mode === "verdict_protocol" ? classifyReview(readiness?.review_workflow) : [];
90
92
  if (review === "malformed") {
91
93
  return { section: null, missing: [], text: UNVERIFIABLE_FALLBACK };
92
94
  }
@@ -101,7 +103,7 @@ export function describeRepositoryWorkflowRemedy(readiness) {
101
103
  return { section: null, missing: [], text: "" };
102
104
  }
103
105
  function build(section, missing) {
104
- return { section, missing, text: missing.map((token) => TEMPLATES[token]).join(" ") };
106
+ return { section, missing, text: `${missing.map((token) => TEMPLATES[token]).join(" ")} ${GUIDE_SENTENCE}` };
105
107
  }
106
108
  /**
107
109
  * The remedy sentence for a repository whose workflows are not confirmed. Never
@@ -121,5 +123,5 @@ export function ciWorkflowRemedy(c) {
121
123
  const tokens = classifyCi(c);
122
124
  if (tokens === "malformed" || tokens.length === 0)
123
125
  return UNVERIFIABLE_FALLBACK;
124
- return tokens.map((token) => TEMPLATES[token]).join(" ");
126
+ return `${tokens.map((token) => TEMPLATES[token]).join(" ")} ${GUIDE_SENTENCE}`;
125
127
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Store-aware merge-credential resolution at privileged action boundaries
3
+ * (BAPI-1461).
4
+ *
5
+ * `merge-credential.ts` owns the precedence and stays free of filesystem and
6
+ * process dependencies. This module is the thin layer that binds it to one
7
+ * repository's stored entry, so every action boundary — a merge job, the
8
+ * `sync_base` integration action, the merge-child recovery verb, the
9
+ * `drive-epic` launch probe, and doctor — resolves the token the same way:
10
+ *
11
+ * `GITHUB_MERGE_TOKEN` → `BAPI_LOCAL_MERGE_TOKEN` → the stored
12
+ * `github-merge:github.com/<owner>/<repo>` entry.
13
+ *
14
+ * The store is read afresh at each call, never cached, so an executor that
15
+ * started before the token was stored still finds it when a merge runs.
16
+ *
17
+ * ELIGIBILITY is always an explicit input. A durable-host service executor
18
+ * (started with `--scrub-inherited-github-credentials`) passes `false` and so
19
+ * never reads a stored GitHub credential. Machine-user mode never reads it.
20
+ */
21
+ import { execFile } from "node:child_process";
22
+ import { readFile } from "node:fs/promises";
23
+ import os from "node:os";
24
+ import { readGithubMergeCredential, resolveGithubMergeRepositoryIdentity, } from "../credential-store.js";
25
+ import { parseGithubRemote } from "../github-remote.js";
26
+ import { resolveLocalMergeCredentialDetailedAsync, } from "./merge-credential.js";
27
+ /** Real host-local read deps for the user-scoped credential store. */
28
+ export function nodeGithubMergeCredentialReadDeps(env) {
29
+ return { env, homedir: os.homedir, readFile: (p) => readFile(p, "utf-8") };
30
+ }
31
+ /**
32
+ * Resolve one kind for a privileged action. The repository identity is settled
33
+ * BEFORE any store lookup: an unresolved or conflicting identity reads nothing,
34
+ * so a process can never pick up another repository's entry.
35
+ */
36
+ export async function resolveMergeCredentialAtAction(env, kind, context) {
37
+ const allowed = kind === "local" &&
38
+ context.allowStoredEntry === true &&
39
+ (context.storeDeps !== undefined || context.readStored !== undefined);
40
+ const identity = allowed
41
+ ? resolveGithubMergeRepositoryIdentity({
42
+ providerRepository: context.providerRepository,
43
+ localRemote: context.localRemote,
44
+ })
45
+ : null;
46
+ const { storeDeps, readStored } = context;
47
+ const reader = identity === null || !identity.ok
48
+ ? undefined
49
+ : readStored !== undefined
50
+ ? () => readStored(identity.identity)
51
+ : storeDeps !== undefined
52
+ ? () => readGithubMergeCredential(identity.identity, storeDeps)
53
+ : undefined;
54
+ return resolveLocalMergeCredentialDetailedAsync(env, kind, {
55
+ allowStoredEntry: allowed && reader !== undefined,
56
+ ...(reader !== undefined ? { readStoredEntry: reader } : {}),
57
+ });
58
+ }
59
+ /** Bound on the one `git remote get-url origin` read. */
60
+ const GIT_REMOTE_TIMEOUT_MS = 5_000;
61
+ const defaultGitRemoteReader = (cwd) => new Promise((resolve) => {
62
+ execFile("git", ["remote", "get-url", "origin"], { cwd, timeout: GIT_REMOTE_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, (error, stdout) => resolve(error ? null : String(stdout)));
63
+ });
64
+ /**
65
+ * The working tree's github.com `origin` as `owner/repo`, or null. Read-only;
66
+ * any failure (no git, no remote, a non-GitHub host) is null, never an error.
67
+ */
68
+ export async function readLocalGithubRemote(cwd, readRemote = defaultGitRemoteReader) {
69
+ try {
70
+ const url = await readRemote(cwd);
71
+ return url === null ? null : parseGithubRemote(url);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The production probe doctor and setup run against a STORED merge token
3
+ * (BAPI-1461).
4
+ *
5
+ * Kept out of `conductor/doctor.ts` so doctor never imports the merge executor:
6
+ * this module binds the shared, bounded, read-only capability probe
7
+ * (`probeLocalMergeCapability`: `gh --version`, `gh auth status`, two `gh api`
8
+ * GETs) and classifies its answer into the closed verdict doctor understands.
9
+ * The token reaches only the probe's restricted child environment; nothing here
10
+ * returns, logs, or prints it.
11
+ */
12
+ import { classifyLocalGhPreflight, probeLocalMergeCapability, } from "./local-merge.js";
13
+ /** Probe one stored token against one `owner/repo`, returning a closed verdict. */
14
+ export async function probeStoredMergeToken(args) {
15
+ try {
16
+ const verdict = classifyLocalGhPreflight(await probeLocalMergeCapability({
17
+ kind: "local",
18
+ repository: args.repository,
19
+ resolved: { credential: args.credential, invalid: false },
20
+ }, { env: args.env ?? process.env }));
21
+ if (verdict === "passed" || verdict === "credential_invalid" || verdict === "no_access")
22
+ return verdict;
23
+ return "unavailable";
24
+ }
25
+ catch {
26
+ return "unavailable";
27
+ }
28
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Scoped private temporary plan file for a trimmed epic plan (BAPI-1510).
3
+ *
4
+ * `withPlanPath` hands a callback either the operator's original plan path (when
5
+ * nothing is skipped — no filesystem work happens) or a private path holding the
6
+ * trimmed JSON. The temporary directory is created only when needed, the file is
7
+ * created exclusively with mode 0600, and the whole directory is removed in a
8
+ * `finally` after success and failure alike. A cleanup failure produces one
9
+ * sanitized warning (no path, no exception text) and never replaces the callback's
10
+ * result or error.
11
+ */
12
+ import { mkdtemp, open, rm } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ export function createDefaultTemporaryPlanFs() {
16
+ return {
17
+ mkdtemp: (prefix) => mkdtemp(prefix),
18
+ writeExclusive: async (path, data, mode) => {
19
+ const handle = await open(path, "wx", mode);
20
+ try {
21
+ await handle.writeFile(data, "utf8");
22
+ }
23
+ finally {
24
+ await handle.close();
25
+ }
26
+ },
27
+ rmDir: (path) => rm(path, { recursive: true, force: true }),
28
+ tmpdir: () => tmpdir(),
29
+ };
30
+ }
31
+ export const TEMPORARY_PLAN_CLEANUP_WARNING = "Could not remove a temporary plan file; it is private to your user and safe to delete.";
32
+ export async function withPlanPath(options, callback) {
33
+ if (!options.skipped)
34
+ return callback(options.originalPath);
35
+ const fs = options.fs ?? createDefaultTemporaryPlanFs();
36
+ const dir = await fs.mkdtemp(join(fs.tmpdir(), "bridge-trimmed-plan-"));
37
+ try {
38
+ const planPath = join(dir, "epic-plan.dag.json");
39
+ await fs.writeExclusive(planPath, options.trimmedJson, 0o600);
40
+ return await callback(planPath);
41
+ }
42
+ finally {
43
+ try {
44
+ await fs.rmDir(dir);
45
+ }
46
+ catch {
47
+ options.warn(TEMPORARY_PLAN_CLEANUP_WARNING);
48
+ }
49
+ }
50
+ }