@bridge_gpt/mcp-server 0.2.42 → 0.2.43

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.
@@ -45,6 +45,7 @@ import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerA
45
45
  import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
46
46
  import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
47
47
  import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
48
+ import { fetchLatestVersion } from "../cli-release.js";
48
49
  import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
49
50
  // BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
50
51
  // local-git helpers live in ONE shared module that `setup-epic` drives too. This
@@ -125,6 +126,7 @@ export function createDefaultConductEpicDeps() {
125
126
  log: (m) => console.log(m),
126
127
  errorLog: (m) => console.error(m),
127
128
  resolveAccess: resolveConductorBridgeApiAccess,
129
+ resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
128
130
  resolveRepoName: resolveRequiredStartTicketsRepoName,
129
131
  };
130
132
  }
@@ -568,6 +570,182 @@ function elapsedSeconds(from, now) {
568
570
  function inFlightTicket(checkpoint) {
569
571
  return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
570
572
  }
573
+ // ---------------------------------------------------------------------------
574
+ // The published-build identity (BAPI-873)
575
+ // ---------------------------------------------------------------------------
576
+ /**
577
+ * Hard bound on launching the published package to read its identity. `npx` may
578
+ * have to download a tarball on a cold cache, so this is generous relative to
579
+ * the registry lookup — but it is a bound, because a wedged launch must never
580
+ * stall `init` indefinitely.
581
+ */
582
+ export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
583
+ /** The identity shape `--version` emits: 12 lowercase hex, optionally `-dirty`. */
584
+ const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
585
+ /** The sentinel a build with no git metadata reports. */
586
+ const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
587
+ /** Human wording for each unavailable category, for the fail-open advisory. */
588
+ export function describePublishedIdentityReason(reason) {
589
+ switch (reason) {
590
+ case "registry_unreadable":
591
+ return "the npm registry could not be read";
592
+ case "launch_failed":
593
+ return "the published package could not be launched";
594
+ case "unreadable_output":
595
+ return "the published package reported no readable build identity";
596
+ case "identity_unknown":
597
+ return "the published build reports an unknown build commit";
598
+ }
599
+ }
600
+ /**
601
+ * Read the commit identity embedded in the LATEST PUBLISHED package.
602
+ *
603
+ * Two bounded steps: resolve the exact latest version through the shared
604
+ * registry lookup, then run THAT EXACT VERSION with `--version`. The exactness
605
+ * matters — invoking a moving `@latest` would read whatever the registry served
606
+ * at that instant, so the version reported and the version inspected could
607
+ * differ, and the gate would be comparing an identity to the wrong build.
608
+ *
609
+ * Every failure is `unavailable`, never a mismatch: not knowing what was
610
+ * published is a different fact from knowing it is wrong, and only the second
611
+ * may block a run.
612
+ */
613
+ export async function readPublishedBuildIdentity(deps) {
614
+ const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
615
+ let version;
616
+ try {
617
+ version = await resolveVersion();
618
+ }
619
+ catch {
620
+ return { kind: "unavailable", reason: "registry_unreadable" };
621
+ }
622
+ if (typeof version !== "string" || version.trim().length === 0) {
623
+ return { kind: "unavailable", reason: "registry_unreadable" };
624
+ }
625
+ const resolvedVersion = version.trim();
626
+ let probe;
627
+ try {
628
+ probe = await deps.runCommand("npx", ["-y", `${MCP_PACKAGE_NAME}@${resolvedVersion}`, "--version"], { cwd: deps.cwd, timeoutMs: PUBLISHED_IDENTITY_TIMEOUT_MS });
629
+ }
630
+ catch {
631
+ return { kind: "unavailable", reason: "launch_failed" };
632
+ }
633
+ if (!probe || probe.exitCode !== 0) {
634
+ return { kind: "unavailable", reason: "launch_failed" };
635
+ }
636
+ const lines = String(probe.stdout ?? "")
637
+ .split("\n")
638
+ .map((line) => line.trim())
639
+ .filter((line) => line.length > 0);
640
+ // The first line is the semver contract `--version` has always emitted. It
641
+ // must be the version we asked for, or the output does not describe the build
642
+ // this reader resolved.
643
+ if (lines[0] !== resolvedVersion) {
644
+ return { kind: "unavailable", reason: "unreadable_output" };
645
+ }
646
+ const commitLine = lines.slice(1).find((line) => line.startsWith("commit:"));
647
+ if (commitLine === undefined) {
648
+ return { kind: "unavailable", reason: "unreadable_output" };
649
+ }
650
+ if (commitLine === `commit: ${PUBLISHED_IDENTITY_UNKNOWN}`) {
651
+ return { kind: "unavailable", reason: "identity_unknown" };
652
+ }
653
+ const match = PUBLISHED_IDENTITY_PATTERN.exec(commitLine);
654
+ if (match === null) {
655
+ return { kind: "unavailable", reason: "unreadable_output" };
656
+ }
657
+ return { kind: "known", version: resolvedVersion, commit: match[1], dirty: match[2] !== undefined };
658
+ }
659
+ /**
660
+ * Decide whether the PUBLISHED build carries the code this epic will be cut at.
661
+ *
662
+ * The gate is expressed as CONTAINMENT, not as a version floor and not as an
663
+ * exact-commit match. "The published build is at least as new as the commit we
664
+ * are conducting" is the property that actually matters, and it is the property
665
+ * a version number could never express: the same semver spanned three different
666
+ * contents, which is why the old floor was unverifiable.
667
+ *
668
+ * Blocking and fail-open are separated deliberately. Knowing the published build
669
+ * is wrong blocks. NOT knowing what was published — a registry outage, a cold
670
+ * npx launch that failed, a build with no git metadata — is an advisory, because
671
+ * a network problem must never stop a run.
672
+ */
673
+ export async function evaluatePublishGate(deps, expectedCommitSha) {
674
+ if (expectedCommitSha === null) {
675
+ // The canonical-index check already recorded its own failure; adding a
676
+ // second one for the same root cause only pads the report.
677
+ return {
678
+ failures: [],
679
+ advisories: [
680
+ "advisory: the publish gate was not evaluated because no canonical indexed commit is available to check against.",
681
+ ],
682
+ };
683
+ }
684
+ const expected = normalizeCommitSha(expectedCommitSha);
685
+ if (expected === null) {
686
+ return {
687
+ failures: [
688
+ "The publish gate cannot be evaluated: the expected commit is not a full 40-character SHA. " +
689
+ "Refusing rather than comparing an arbitrary prefix.",
690
+ ],
691
+ advisories: [],
692
+ };
693
+ }
694
+ const identity = await readPublishedBuildIdentity(deps);
695
+ if (identity.kind === "unavailable") {
696
+ return {
697
+ failures: [],
698
+ advisories: [
699
+ `advisory: the publish gate could not be verified — ${describePublishedIdentityReason(identity.reason)}. ` +
700
+ `Initialization is continuing; the published ${MCP_PACKAGE_NAME} build was NOT confirmed to contain ${expected}.`,
701
+ ],
702
+ };
703
+ }
704
+ if (identity.dirty) {
705
+ return {
706
+ failures: [
707
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} reports build commit ${identity.commit}-dirty. ` +
708
+ "A dirty build carries content that no commit represents, so it cannot be verified to contain " +
709
+ `${expected}. Publish a build from a clean checkout.`,
710
+ ],
711
+ advisories: [],
712
+ };
713
+ }
714
+ // The published SHA must be an object THIS checkout knows about before any
715
+ // ancestry claim is possible. It is a short SHA, so it cannot be fetched by
716
+ // name — an unresolvable one is "cannot verify", never "wrong".
717
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${identity.commit}^{commit}`]);
718
+ if (present.exitCode !== 0) {
719
+ return {
720
+ failures: [],
721
+ advisories: [
722
+ `advisory: the published ${MCP_PACKAGE_NAME}@${identity.version} build commit ${identity.commit} ` +
723
+ "is not present in this checkout, so the publish gate could not be verified. " +
724
+ `Initialization is continuing; fetch origin and confirm that build contains ${expected}.`,
725
+ ],
726
+ };
727
+ }
728
+ const contains = await git(deps, ["merge-base", "--is-ancestor", expected, identity.commit]);
729
+ if (contains.exitCode === 0)
730
+ return { failures: [], advisories: [] };
731
+ if (contains.exitCode === 1) {
732
+ return {
733
+ failures: [
734
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
735
+ `which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
736
+ "Publish a build containing that commit before initializing.",
737
+ ],
738
+ advisories: [],
739
+ };
740
+ }
741
+ return {
742
+ failures: [],
743
+ advisories: [
744
+ `advisory: the publish gate could not be verified — the ancestry of published build commit ` +
745
+ `${identity.commit} could not be determined locally. Initialization is continuing.`,
746
+ ],
747
+ };
748
+ }
571
749
  /**
572
750
  * Run every independent `init` check and ACCUMULATE the failures.
573
751
  *
@@ -588,6 +766,7 @@ function inFlightTicket(checkpoint) {
588
766
  export async function collectConductEpicInitPreflight(deps, options) {
589
767
  const failures = [];
590
768
  const announcements = [];
769
+ const advisories = [];
591
770
  const epicBranch = epicBranchFor(options.epicKey);
592
771
  let pendingSupervisorConfig = null;
593
772
  // (1) gh authentication.
@@ -760,6 +939,16 @@ export async function collectConductEpicInitPreflight(deps, options) {
760
939
  }
761
940
  }
762
941
  }
942
+ // (13) BAPI-873: the publish gate. Evaluated HERE — after the cut commit is
943
+ // known and proven present locally, and still before anything mutates —
944
+ // because the gate's question is whether the PUBLISHED package (the one
945
+ // `start-tickets` spawns for every worker) already carries the code this
946
+ // epic is cut at. A readable mismatch joins `failures` so it is reported
947
+ // alongside every other readiness problem; an unreadable published identity
948
+ // becomes an advisory and the run continues.
949
+ const publishGate = await evaluatePublishGate(deps, cutCommitSha);
950
+ failures.push(...publishGate.failures);
951
+ advisories.push(...publishGate.advisories);
763
952
  // (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
764
953
  // canonical indexed commit. An epic branch sitting at ANY other commit still
765
954
  // fails closed — including the base tip, which is no longer special.
@@ -797,6 +986,7 @@ export async function collectConductEpicInitPreflight(deps, options) {
797
986
  return {
798
987
  failures,
799
988
  announcements,
989
+ advisories,
800
990
  access,
801
991
  baseBranch,
802
992
  baseSha,
@@ -925,6 +1115,11 @@ export async function runConductEpicInit(deps, options) {
925
1115
  ], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
926
1116
  }
927
1117
  const preflight = await collectConductEpicInitPreflight(deps, options);
1118
+ // BAPI-873: fail-open advisories are reported before the outcome is decided,
1119
+ // on stderr, on both paths — an advisory that only printed on failure would
1120
+ // let a run proceed silently past an unverified publish gate.
1121
+ for (const line of preflight.advisories)
1122
+ deps.errorLog(line);
928
1123
  if (preflight.failures.length > 0) {
929
1124
  for (const line of preflight.announcements)
930
1125
  deps.errorLog(line);
@@ -214,6 +214,20 @@ export function extractSanitizedErrorDiagnostics(body) {
214
214
  diagnostics.errorCode = boundedErrorPreview(errorCode);
215
215
  if (message)
216
216
  diagnostics.bodyPreview = boundedErrorPreview(message);
217
+ // BAPI-872: extract the allowlisted stale-CAS `current_row_version`, from
218
+ // either the nested `detail` object or (defensively) the top level. Only a
219
+ // genuine non-negative safe integer is accepted — never a boolean (JS has no
220
+ // strict-int check, so `Number.isSafeInteger` alone would still accept `1`
221
+ // but not `true`), never a string, never negative — so a malformed value can
222
+ // never be mistaken for a usable CAS conflict downstream.
223
+ const rawCurrentRowVersion = detail && typeof detail === "object" && !Array.isArray(detail)
224
+ ? detail["current_row_version"]
225
+ : record["current_row_version"];
226
+ if (typeof rawCurrentRowVersion === "number" &&
227
+ Number.isSafeInteger(rawCurrentRowVersion) &&
228
+ rawCurrentRowVersion >= 0) {
229
+ diagnostics.currentRowVersion = rawCurrentRowVersion;
230
+ }
217
231
  return diagnostics;
218
232
  }
219
233
  /** Redact exact secret substrings (e.g. the API key from the request headers). */
@@ -231,6 +245,10 @@ function redactDiagnosticValues(diagnostics, secrets) {
231
245
  out.errorCode = scrub(diagnostics.errorCode);
232
246
  if (diagnostics.bodyPreview)
233
247
  out.bodyPreview = scrub(diagnostics.bodyPreview);
248
+ // A number has nothing to scrub; carried through unchanged (BAPI-872).
249
+ if (typeof diagnostics.currentRowVersion === "number") {
250
+ out.currentRowVersion = diagnostics.currentRowVersion;
251
+ }
234
252
  return out;
235
253
  }
236
254
  /**
@@ -267,6 +285,13 @@ export class ConductorBridgeApiError extends Error {
267
285
  status;
268
286
  errorCode;
269
287
  bodyPreview;
288
+ /**
289
+ * The stale-CAS `current_row_version` (BAPI-872), when the backend supplied
290
+ * one. Deliberately NOT interpolated into `.message` (unlike `bodyPreview`) —
291
+ * every recovery CLI surface must never print a row-version value, and callers
292
+ * that need it read this field directly.
293
+ */
294
+ currentRowVersion;
270
295
  constructor(kindOrMessage, status, diagnostics) {
271
296
  const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
272
297
  const errorCode = diagnostics?.errorCode;
@@ -293,6 +318,9 @@ export class ConductorBridgeApiError extends Error {
293
318
  this.errorCode = errorCode;
294
319
  if (bodyPreview)
295
320
  this.bodyPreview = bodyPreview;
321
+ if (typeof diagnostics?.currentRowVersion === "number") {
322
+ this.currentRowVersion = diagnostics.currentRowVersion;
323
+ }
296
324
  }
297
325
  }
298
326
  /**
@@ -873,6 +901,27 @@ export async function updateEpicRunStatus(access, request, fetchImpl = globalThi
873
901
  const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
874
902
  return parsed;
875
903
  }
904
+ /**
905
+ * POST `/jira/epic-runs/runs/{epic_run_id}/stop` — the operator emergency brake
906
+ * (BAPI-732, wired to the conductor CLI in BAPI-872). Idempotent: a repeated stop
907
+ * returns HTTP 200 with `committed: false`; a terminal run (`done`/`abandoned`)
908
+ * returns a `409 RUN_TERMINAL` conflict, which surfaces as a thrown
909
+ * {@link ConductorBridgeApiError} with `status: 409` and `errorCode: "RUN_TERMINAL"`.
910
+ * Transport/auth/server failures throw a sanitized {@link ConductorBridgeApiError}.
911
+ */
912
+ export async function stopEpicRun(access, request, fetchImpl = globalThis.fetch) {
913
+ requireNonEmptyString(request.epicRunId);
914
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/stop`);
915
+ const body = JSON.stringify({
916
+ repo_name: access.repoName,
917
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
918
+ ...(request.expectedGeneration !== undefined
919
+ ? { expected_generation: request.expectedGeneration }
920
+ : {}),
921
+ });
922
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
923
+ return parsed;
924
+ }
876
925
  // ---------------------------------------------------------------------------
877
926
  // Per-ticket CAS status advancement
878
927
  // ---------------------------------------------------------------------------
@@ -937,6 +986,78 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = globa
937
986
  const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
938
987
  return parseAdvanceEpicTicketStatusResult(parsed);
939
988
  }
989
+ // ---------------------------------------------------------------------------
990
+ // Operator recovery: ticket unpark + adopt-current-head-and-unpark (BAPI-872)
991
+ // ---------------------------------------------------------------------------
992
+ /**
993
+ * Catch a stale-CAS 400 thrown by the POST transport and translate it into the
994
+ * SAME `{ok: false, kind: "cas-conflict", current_row_version}` result
995
+ * {@link parseAdvanceEpicTicketStatusResult} already produces for the PATCH CAS
996
+ * endpoint — the previously dormant conflict branch that now becomes production
997
+ * behavior for both unpark lanes. Only a genuine stale-CAS shape (400 status with
998
+ * a validated `currentRowVersion`) is translated; every other failure re-throws
999
+ * unchanged, preserving `ConductorBridgeApiError` for terminal, validation,
1000
+ * authorization, and transport failures.
1001
+ */
1002
+ async function postUnparkLikeRequest(url, headers, body, fetchImpl) {
1003
+ try {
1004
+ const parsed = await fetchConductorJsonPostWithTimeout(url, headers, body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1005
+ return parseAdvanceEpicTicketStatusResult(parsed);
1006
+ }
1007
+ catch (err) {
1008
+ if (err instanceof ConductorBridgeApiError &&
1009
+ err.status === 400 &&
1010
+ typeof err.currentRowVersion === "number") {
1011
+ return parseAdvanceEpicTicketStatusResult({
1012
+ ok: false,
1013
+ kind: "cas-conflict",
1014
+ current_row_version: err.currentRowVersion,
1015
+ });
1016
+ }
1017
+ throw err;
1018
+ }
1019
+ }
1020
+ /**
1021
+ * POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/unpark` — move a
1022
+ * parked `needs_human` ticket back into its gate machine (BAPI-536, wired to the
1023
+ * conductor CLI in BAPI-872). A stale `expectedRowVersion` surfaces as the
1024
+ * discriminated `{ok: false, kind: "cas-conflict", current_row_version}` result
1025
+ * rather than a thrown error, so a bounded caller-side retry can re-read state and
1026
+ * try again without special-casing an exception.
1027
+ */
1028
+ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.fetch) {
1029
+ requireNonEmptyString(request.epicRunId);
1030
+ requireNonEmptyString(request.ticketKey);
1031
+ requireNonNegativeSafeInteger(request.expectedRowVersion);
1032
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/unpark`);
1033
+ const body = JSON.stringify({
1034
+ repo_name: access.repoName,
1035
+ expected_row_version: request.expectedRowVersion,
1036
+ ...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
1037
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
1038
+ });
1039
+ return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
1040
+ }
1041
+ /**
1042
+ * POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
1043
+ * — the recovery lane for a human/external push that drifted the PR head off the
1044
+ * ticket's anchored `expected_head_sha` (BAPI-571 B3, wired to the conductor CLI
1045
+ * in BAPI-872). Same CAS-conflict/result shape as {@link unparkEpicTicket}; shares
1046
+ * {@link parseAdvanceEpicTicketStatusResult} rather than a second parser.
1047
+ */
1048
+ export async function adoptCurrentHeadAndUnparkTicket(access, request, fetchImpl = globalThis.fetch) {
1049
+ requireNonEmptyString(request.epicRunId);
1050
+ requireNonEmptyString(request.ticketKey);
1051
+ requireNonNegativeSafeInteger(request.expectedRowVersion);
1052
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/adopt-current-head-and-unpark`);
1053
+ const body = JSON.stringify({
1054
+ repo_name: access.repoName,
1055
+ expected_row_version: request.expectedRowVersion,
1056
+ ...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
1057
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
1058
+ });
1059
+ return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
1060
+ }
940
1061
  /**
941
1062
  * Idempotently seed an epic ticket status row via POST to the per-epic tickets
942
1063
  * endpoint. The backend uses ON CONFLICT DO NOTHING so repeated seeding across
@@ -65,6 +65,15 @@ export function getConductorUsage() {
65
65
  " Run the reference-transaction producer (invoked by the hook)",
66
66
  " file-scope-guard Warn-only: compare the branch diff against the declared",
67
67
  " touched-file set (always exits 0; never blocks a PR)",
68
+ " stop-run Stop an epic run: block dispatch and cancel queued work",
69
+ " (the run record is preserved). Idempotent.",
70
+ " abandon-run Abandon an epic run — TERMINAL and IRREVERSIBLE.",
71
+ " Idempotent on an already-abandoned run.",
72
+ " unpark Move a parked (needs_human) ticket back into its gate",
73
+ " machine. Retries internally on a concurrent change.",
74
+ " adopt-current-head-and-unpark",
75
+ " Recover a ticket parked by a PR-head drift: adopt the",
76
+ " CURRENT PR head and unpark in one step.",
68
77
  "",
69
78
  "supervise options:",
70
79
  " --run-id <id> Run/session identifier to supervise (required)",
@@ -157,6 +166,37 @@ export function getConductorUsage() {
157
166
  " conductor approve-plan EPIC-405 --plan-version 2 --json",
158
167
  " conductor epic-status --epic-key EPIC-405",
159
168
  " conductor epic-status --epic-key EPIC-405 --json",
169
+ "",
170
+ "stop-run / abandon-run options:",
171
+ " --epic-run-id <id> Epic run identifier (required)",
172
+ " --json Print compact JSON result",
173
+ " --help Print this usage message",
174
+ " stop-run BLOCKS new dispatch and CANCELS queued work; the run record is",
175
+ " preserved and the run cannot resume on its own — it is an emergency brake,",
176
+ " not a pause. abandon-run is the SAME plus a TERMINAL, IRREVERSIBLE close-out:",
177
+ " once abandoned, the run can never resume or be reused. Both are idempotent —",
178
+ " repeating either is a safe no-op once the run is already stopped/abandoned.",
179
+ "",
180
+ "unpark / adopt-current-head-and-unpark options:",
181
+ " --epic-run-id <id> Epic run identifier (required)",
182
+ " --ticket-key <key> Ticket key (required)",
183
+ " --json Print compact JSON result",
184
+ " --help Print this usage message",
185
+ " unpark resumes a ticket PARKED with needs_human, once the underlying cause",
186
+ " is resolved. adopt-current-head-and-unpark additionally REBINDS the ticket's",
187
+ " anchored PR head to whatever the PR's head currently is, for a ticket parked",
188
+ " by a human/external push. Both retry internally on a concurrent row change —",
189
+ " neither accepts, nor ever asks for, a version counter.",
190
+ "",
191
+ "Incident examples:",
192
+ " # A run is misbehaving and must stop immediately:",
193
+ " conductor stop-run --epic-run-id 3f9c2b7e-...",
194
+ " # The run is done for good — close it out:",
195
+ " conductor abandon-run --epic-run-id 3f9c2b7e-...",
196
+ " # A ticket parked itself (needs_human) and the cause is now fixed:",
197
+ " conductor unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
198
+ " # A human pushed directly to the PR and the ticket parked on head drift:",
199
+ " conductor adopt-current-head-and-unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
160
200
  ].join("\n");
161
201
  }
162
202
  /**
@@ -180,6 +220,11 @@ const VALID_COMMANDS = new Set([
180
220
  "install-git-hooks",
181
221
  "git-hook",
182
222
  "file-scope-guard",
223
+ // BAPI-872: CLI-only operator recovery verbs — never registered as MCP tools.
224
+ "stop-run",
225
+ "abandon-run",
226
+ "unpark",
227
+ "adopt-current-head-and-unpark",
183
228
  // Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
184
229
  // the bundled-artifact regression guard for BAPI-772 (mirroring `plane
185
230
  // __entrypoint`), not as a supported operator workflow.
@@ -1152,6 +1197,24 @@ export async function runConductorCli(argv) {
1152
1197
  case "file-scope-guard":
1153
1198
  // BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
1154
1199
  return runFileScopeGuardCli();
1200
+ case "stop-run": {
1201
+ // BAPI-872: lazily imported so the Bridge credential/HTTP graph never
1202
+ // loads for a local-only command (doctor, emit-event, etc.).
1203
+ const { runStopRunCommand } = await import("./recovery-cli.js");
1204
+ return await runStopRunCommand(parsed.argv);
1205
+ }
1206
+ case "abandon-run": {
1207
+ const { runAbandonRunCommand } = await import("./recovery-cli.js");
1208
+ return await runAbandonRunCommand(parsed.argv);
1209
+ }
1210
+ case "unpark": {
1211
+ const { runUnparkCommand } = await import("./recovery-cli.js");
1212
+ return await runUnparkCommand(parsed.argv);
1213
+ }
1214
+ case "adopt-current-head-and-unpark": {
1215
+ const { runAdoptCurrentHeadAndUnparkCommand } = await import("./recovery-cli.js");
1216
+ return await runAdoptCurrentHeadAndUnparkCommand(parsed.argv);
1217
+ }
1155
1218
  default:
1156
1219
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1157
1220
  return 1;