@bridge_gpt/mcp-server 0.2.41 → 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.
Files changed (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -485,38 +485,42 @@ export function makeLocalMergeExecutor(options = {}, deps = {}) {
485
485
  }
486
486
  // 3. Revalidate required CI green for the EXACT expected head, waiting briefly
487
487
  // for transient post-remediation CI to turn green before failing ci_not_green.
488
- const ciWait = await waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredChecks, ciWaitTimeoutMs, ciWaitPollIntervalMs, sleep, now, signal);
489
- if (!ciWait.ok) {
490
- // Any `ci_poll_*` reason (the legacy `ci_poll_failed` plus the distinct
491
- // BAPI-577 kinds) is an AMBIGUOUS infrastructure read failure a merge that
492
- // GitHub already accepted could be masked by a flaky poll. Re-verify the
493
- // merged state before reporting. ci_not_green and merge_aborted are
494
- // deterministic (CI is red / the job was aborted); they never verify and
495
- // never proceed to a provider merge.
496
- if (isAmbiguousCiPollFailureReason(ciWait.reason)) {
488
+ // Skipped under `skipCiWait` (BAPI-832), where the server's approval tail
489
+ // revalidated these same checks for this same head moments ago.
490
+ if (!options.skipCiWait) {
491
+ const ciWait = await waitForRequiredChecksGreen(pollCi, access, expectedSha, requiredChecks, ciWaitTimeoutMs, ciWaitPollIntervalMs, sleep, now, signal);
492
+ if (!ciWait.ok) {
493
+ // Any `ci_poll_*` reason (the legacy `ci_poll_failed` plus the distinct
494
+ // BAPI-577 kinds) is an AMBIGUOUS infrastructure read failure a merge that
495
+ // GitHub already accepted could be masked by a flaky poll. Re-verify the
496
+ // merged state before reporting. ci_not_green and merge_aborted are
497
+ // deterministic (CI is red / the job was aborted); they never verify and
498
+ // never proceed to a provider merge.
499
+ if (isAmbiguousCiPollFailureReason(ciWait.reason)) {
500
+ const verified = await verifyMergedStateAfterAmbiguousFailure();
501
+ if (verified)
502
+ return verified;
503
+ }
504
+ return fail(ciWait.reason, ciWait.pollFailure);
505
+ }
506
+ // 3b. Re-read PR head/state after the CI wait — the head can drift, the PR can
507
+ // close, or the merge can already have landed while we waited for CI.
508
+ const secondRead = await readPrMergeState(run, ghEnv, pr);
509
+ if (!secondRead.ok) {
497
510
  const verified = await verifyMergedStateAfterAmbiguousFailure();
498
511
  if (verified)
499
512
  return verified;
513
+ return fail(secondRead.reason);
514
+ }
515
+ if (isMergedAtExpectedHead(secondRead.state, secondRead.headOid, expectedSha)) {
516
+ return buildAlreadyMergedResponse(request, baseDetails, secondRead.mergeCommitOid);
517
+ }
518
+ if (typeof secondRead.headOid !== "string" || secondRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
519
+ return fail("head_drift");
520
+ }
521
+ if (typeof secondRead.state === "string" && secondRead.state.toUpperCase() !== "OPEN") {
522
+ return fail("pr_not_open");
500
523
  }
501
- return fail(ciWait.reason, ciWait.pollFailure);
502
- }
503
- // 3b. Re-read PR head/state after the CI wait — the head can drift, the PR can
504
- // close, or the merge can already have landed while we waited for CI.
505
- const secondRead = await readPrMergeState(run, ghEnv, pr);
506
- if (!secondRead.ok) {
507
- const verified = await verifyMergedStateAfterAmbiguousFailure();
508
- if (verified)
509
- return verified;
510
- return fail(secondRead.reason);
511
- }
512
- if (isMergedAtExpectedHead(secondRead.state, secondRead.headOid, expectedSha)) {
513
- return buildAlreadyMergedResponse(request, baseDetails, secondRead.mergeCommitOid);
514
- }
515
- if (typeof secondRead.headOid !== "string" || secondRead.headOid.toLowerCase() !== expectedSha.toLowerCase()) {
516
- return fail("head_drift");
517
- }
518
- if (typeof secondRead.state === "string" && secondRead.state.toUpperCase() !== "OPEN") {
519
- return fail("pr_not_open");
520
524
  }
521
525
  // 4. Provider merge. Final abort guard: if the overall timeout / ownership
522
526
  // abandonment fired during the CI wait or the post-CI re-read, do NOT fire
@@ -619,3 +623,101 @@ function buildConflictResponse(request, baseDetails, expectedSha, mergeability)
619
623
  { type: "merge.conflict", status: "failed", reason, details: conflictDetails },
620
624
  ]);
621
625
  }
626
+ /**
627
+ * Probe whether this host can merge with `gh` at all, BEFORE any merge is
628
+ * attempted.
629
+ *
630
+ * Without this the two operator-fixable failures are invisible: a missing `gh`
631
+ * and a logged-out `gh` both make `gh pr view` exit non-zero, which this module
632
+ * reports as `gh_pr_view_failed` — a reason that names no fix. Splitting them
633
+ * out is the difference between "run `gh auth login`" and "something went wrong
634
+ * reading the pull request".
635
+ *
636
+ * Deliberately async and built on the same `run` seam as the merge itself. The
637
+ * read-only equivalent in `conductor/doctor.ts` (`inspectLocalMerge`) probes
638
+ * synchronously, which is fine for a diagnostic command but would block the MCP
639
+ * server's event loop for up to its full timeout in the middle of a tool call.
640
+ * (This module's static contract forbids synchronous process execution outright,
641
+ * so the token itself cannot appear here — not even in a comment.)
642
+ */
643
+ export async function preflightLocalGh(run, ghEnv) {
644
+ let version;
645
+ try {
646
+ version = await run("gh", ["--version"], ghEnv);
647
+ }
648
+ catch {
649
+ return { ok: false, reason: "local_gh_unavailable" };
650
+ }
651
+ if (version.status !== 0)
652
+ return { ok: false, reason: "local_gh_unavailable" };
653
+ let auth;
654
+ try {
655
+ auth = await run("gh", ["auth", "status"], ghEnv);
656
+ }
657
+ catch {
658
+ return { ok: false, reason: "local_gh_unauthenticated" };
659
+ }
660
+ if (auth.status !== 0)
661
+ return { ok: false, reason: "local_gh_unauthenticated" };
662
+ return { ok: true };
663
+ }
664
+ /**
665
+ * Execute a merge the server has ALREADY approved, using the operator's `gh`.
666
+ *
667
+ * This is the local half of BAPI-832's split: the server ran the whole
668
+ * deterministic decision tail (action key, lease, head-SHA drift guard, review
669
+ * gate, workflow-file guard, CI revalidation) and returned
670
+ * `approved_for_local_execution` because its GitHub App installation is
671
+ * `contents: read` and cannot perform the write. Only the write happens here.
672
+ *
673
+ * It reuses {@link makeLocalMergeExecutor} rather than reimplementing the merge,
674
+ * so the head-drift guard, the `--match-head-commit` binding, and the entire
675
+ * ambiguity-resolution ladder are the same proven code v2's `merge` job runs.
676
+ * `skipCiWait` is set because the server revalidated CI moments ago.
677
+ *
678
+ * This function grants no authority of its own. It must only ever be called with
679
+ * a request built from a live approval for that exact PR and head SHA.
680
+ */
681
+ export async function runApprovedLocalMerge(request, options = {}, deps = {}) {
682
+ const ghEnv = {
683
+ ...deps.env,
684
+ GH_PROMPT_DISABLED: "1",
685
+ GH_NO_UPDATE_NOTIFIER: "1",
686
+ };
687
+ const rawRun = deps.runCommand ?? defaultRunCommand;
688
+ const run = (cmd, args, env) => Promise.resolve(rawRun(cmd, args, env, deps.signal));
689
+ const preflight = await preflightLocalGh(run, ghEnv);
690
+ if (!preflight.ok) {
691
+ return buildResponse(request, "failed", preflight.reason, false, [
692
+ {
693
+ type: "merge.failed",
694
+ status: "failed",
695
+ reason: preflight.reason,
696
+ details: {
697
+ action_key: request.action_key,
698
+ repo: request.repo_name,
699
+ pr_number: request.pr_number,
700
+ expected_head_sha: request.expected_head_sha,
701
+ executor: "local",
702
+ },
703
+ },
704
+ ]);
705
+ }
706
+ const executor = makeLocalMergeExecutor({ method: options.method, skipCiWait: true }, deps);
707
+ // `access` is only ever used by the CI poll, which `skipCiWait` bypasses. The
708
+ // throwing stub makes that structural, not a comment: if a future edit ever
709
+ // reaches the poll from this path, it fails loudly in tests instead of
710
+ // silently polling with an unusable access record.
711
+ const unusedAccess = {
712
+ get baseUrl() {
713
+ throw new Error("runApprovedLocalMerge must not poll CI");
714
+ },
715
+ get apiKey() {
716
+ throw new Error("runApprovedLocalMerge must not poll CI");
717
+ },
718
+ get repoName() {
719
+ throw new Error("runApprovedLocalMerge must not poll CI");
720
+ },
721
+ };
722
+ return executor(unusedAccess, request);
723
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * `conductor` operator recovery verbs (BAPI-872): `stop-run`, `abandon-run`,
3
+ * `unpark`, `adopt-current-head-and-unpark`.
4
+ *
5
+ * CLI-only by design — this is an explicit ticket non-goal, not an oversight:
6
+ * none of these four command names is ever registered as an MCP tool, tool
7
+ * description, or generated tool-schema input (`index-static.test.ts` guards
8
+ * this). They exist so an operator (or `plane down`, via
9
+ * `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
10
+ * hand-rolling an HTTP request — the exact thing setup-epic's old raw-PATCH
11
+ * guidance used to ask for.
12
+ *
13
+ * Every ticket command's `row_version` CAS handling is entirely internal to
14
+ * `recovery-operations.ts`. This surface never accepts, echoes, or asks the
15
+ * operator for a version counter — see {@link REJECTED_VERSION_FLAGS}.
16
+ */
17
+ import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Flag parsing
20
+ // ---------------------------------------------------------------------------
21
+ const RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
22
+ const TICKET_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key"]);
23
+ const RECOVERY_BOOL_FLAGS = new Set(["--json", "--help"]);
24
+ /**
25
+ * Every spelling of an operator-supplied version counter this surface must
26
+ * refuse outright (BAPI-872) — row_version handling is internal to
27
+ * {@link module:./recovery-operations}, and no ticket command may accept one.
28
+ */
29
+ const REJECTED_VERSION_FLAGS = new Set([
30
+ "--row-version",
31
+ "--expected-row-version",
32
+ "--row_version",
33
+ "--expected_row_version",
34
+ ]);
35
+ /**
36
+ * Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
37
+ * verbs. A rejected version flag throws BEFORE its value (if any) is ever
38
+ * read, so the offending value is never echoed, normalized, or invited.
39
+ */
40
+ function tokenizeRecoveryFlags(argv, valueFlags, boolFlags) {
41
+ const values = new Map();
42
+ const bools = new Set();
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ const token = argv[i];
45
+ if (token === "-h") {
46
+ bools.add("--help");
47
+ continue;
48
+ }
49
+ if (!token.startsWith("--")) {
50
+ throw new ConductorValidationError(`Unexpected argument "${token}".`);
51
+ }
52
+ const eq = token.indexOf("=");
53
+ const name = eq >= 0 ? token.slice(0, eq) : token;
54
+ if (REJECTED_VERSION_FLAGS.has(name)) {
55
+ throw new ConductorValidationError(`Flag "${name}" is not supported here — row-version handling is internal to ` +
56
+ "recovery and cannot be supplied by the operator. Omit it; recovery reads the " +
57
+ "current version itself.");
58
+ }
59
+ if (boolFlags.has(name)) {
60
+ bools.add(name);
61
+ continue;
62
+ }
63
+ if (!valueFlags.has(name)) {
64
+ throw new ConductorValidationError(`Unknown flag "${name}".`);
65
+ }
66
+ let value;
67
+ if (eq >= 0) {
68
+ value = token.slice(eq + 1);
69
+ }
70
+ else {
71
+ const next = argv[i + 1];
72
+ if (next === undefined) {
73
+ throw new ConductorValidationError(`Flag "${name}" requires a value.`);
74
+ }
75
+ value = next;
76
+ i += 1;
77
+ }
78
+ values.set(name, value);
79
+ }
80
+ return { values, bools };
81
+ }
82
+ function parseRunRecoveryArgs(argv) {
83
+ const { values, bools } = tokenizeRecoveryFlags(argv, RUN_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
84
+ if (bools.has("--help")) {
85
+ return { epicRunId: "", json: bools.has("--json"), help: true };
86
+ }
87
+ const epicRunId = values.get("--epic-run-id");
88
+ if (epicRunId === undefined || epicRunId.trim().length === 0) {
89
+ throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
90
+ }
91
+ return { epicRunId: epicRunId.trim(), json: bools.has("--json"), help: false };
92
+ }
93
+ function parseTicketRecoveryArgs(argv) {
94
+ const { values, bools } = tokenizeRecoveryFlags(argv, TICKET_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
95
+ if (bools.has("--help")) {
96
+ return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
97
+ }
98
+ const epicRunId = values.get("--epic-run-id");
99
+ if (epicRunId === undefined || epicRunId.trim().length === 0) {
100
+ throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
101
+ }
102
+ const ticketKey = values.get("--ticket-key");
103
+ if (ticketKey === undefined || ticketKey.trim().length === 0) {
104
+ throw new ConductorValidationError('Flag "--ticket-key" is required and must be non-empty.');
105
+ }
106
+ return {
107
+ epicRunId: epicRunId.trim(),
108
+ ticketKey: ticketKey.trim(),
109
+ json: bools.has("--json"),
110
+ help: false,
111
+ };
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Shared access resolution
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * Resolve Bridge API access through the shared credential-store resolution
118
+ * (never `BAPI_API_KEY` read directly). Prints the sanitized failure itself
119
+ * and returns `null` on failure so the caller can return exit code `1`
120
+ * without duplicating the rendering.
121
+ */
122
+ async function resolveRecoveryAccess(json) {
123
+ const { resolveConductorBridgeApiAccess } = await import("./bridge-api-client.js");
124
+ const result = await resolveConductorBridgeApiAccess();
125
+ if (!result.ok) {
126
+ if (json) {
127
+ console.log(JSON.stringify({ ok: false, kind: "unauthorized", error: result.error }));
128
+ }
129
+ else {
130
+ console.error(`Error: ${result.error}`);
131
+ }
132
+ return null;
133
+ }
134
+ return result.access;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // stop-run
138
+ // ---------------------------------------------------------------------------
139
+ const STOP_RUN_USAGE = [
140
+ "Usage: conductor stop-run --epic-run-id <id> [--json]",
141
+ "",
142
+ "Stop an epic run: block new dispatch and cancel its queued work. The run",
143
+ "record is preserved (never deleted) so its history stays inspectable.",
144
+ "Idempotent — repeating it once stopped is a safe no-op.",
145
+ ].join("\n");
146
+ function renderStopRunResult(result, json) {
147
+ if (json) {
148
+ console.log(JSON.stringify(result));
149
+ return result.ok ? 0 : 1;
150
+ }
151
+ if (result.ok && result.kind === "committed") {
152
+ console.log(`Run ${result.epicRunId}: stopped. Its queued work was cancelled; the run record ` +
153
+ "remains available for inspection.");
154
+ return 0;
155
+ }
156
+ if (result.ok && result.kind === "already-stopped") {
157
+ console.log(`Run ${result.epicRunId}: already stopped; no further action.`);
158
+ return 0;
159
+ }
160
+ if (!result.ok && result.kind === "terminal") {
161
+ console.error(`Run ${result.epicRunId}: terminal (${result.status}) and cannot be stopped.`);
162
+ return 1;
163
+ }
164
+ console.error(`Run ${result.epicRunId}: could not be stopped — ${result.message}.`);
165
+ return 1;
166
+ }
167
+ export async function runStopRunCommand(argv) {
168
+ try {
169
+ const parsed = parseRunRecoveryArgs(argv);
170
+ if (parsed.help) {
171
+ console.log(STOP_RUN_USAGE);
172
+ return 0;
173
+ }
174
+ const access = await resolveRecoveryAccess(parsed.json);
175
+ if (!access)
176
+ return 1;
177
+ const { stopEpicRunRecovery } = await import("./recovery-operations.js");
178
+ const result = await stopEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
179
+ return renderStopRunResult(result, parsed.json);
180
+ }
181
+ catch (error) {
182
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
183
+ return 1;
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // abandon-run
188
+ // ---------------------------------------------------------------------------
189
+ const ABANDON_RUN_USAGE = [
190
+ "Usage: conductor abandon-run --epic-run-id <id> [--json]",
191
+ "",
192
+ "Abandon an epic run — a TERMINAL, IRREVERSIBLE transition. An abandoned run",
193
+ "can never resume or be reused; start a new run instead. Idempotent —",
194
+ "repeating it on an already-abandoned run is a safe no-op.",
195
+ ].join("\n");
196
+ function renderAbandonRunResult(result, json) {
197
+ if (json) {
198
+ console.log(JSON.stringify(result));
199
+ return result.ok ? 0 : 1;
200
+ }
201
+ if (result.ok && result.kind === "abandoned") {
202
+ console.log(`Run ${result.epicRunId}: abandoned (terminal).`);
203
+ return 0;
204
+ }
205
+ if (result.ok && result.kind === "already-abandoned") {
206
+ console.log(`Run ${result.epicRunId}: already abandoned (terminal); no further action.`);
207
+ return 0;
208
+ }
209
+ console.error(`Run ${result.epicRunId}: could not be abandoned — ${result.message}.`);
210
+ return 1;
211
+ }
212
+ export async function runAbandonRunCommand(argv) {
213
+ try {
214
+ const parsed = parseRunRecoveryArgs(argv);
215
+ if (parsed.help) {
216
+ console.log(ABANDON_RUN_USAGE);
217
+ return 0;
218
+ }
219
+ const access = await resolveRecoveryAccess(parsed.json);
220
+ if (!access)
221
+ return 1;
222
+ const { abandonEpicRunRecovery } = await import("./recovery-operations.js");
223
+ const result = await abandonEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
224
+ return renderAbandonRunResult(result, parsed.json);
225
+ }
226
+ catch (error) {
227
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
228
+ return 1;
229
+ }
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // unpark / adopt-current-head-and-unpark (shared rendering)
233
+ // ---------------------------------------------------------------------------
234
+ const UNPARK_USAGE = [
235
+ "Usage: conductor unpark --epic-run-id <id> --ticket-key <key> [--json]",
236
+ "",
237
+ "Move a parked (needs_human) ticket back into its gate machine, once the",
238
+ "operator has resolved what parked it. Retries a bounded number of times on",
239
+ "its own if the ticket changes concurrently — never accepts a version counter.",
240
+ ].join("\n");
241
+ const ADOPT_CURRENT_HEAD_USAGE = [
242
+ "Usage: conductor adopt-current-head-and-unpark --epic-run-id <id> --ticket-key <key> [--json]",
243
+ "",
244
+ "Recover a ticket parked because a human/external push drifted the PR head",
245
+ "off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
246
+ "Retries a bounded number of times on its own if the ticket changes",
247
+ "concurrently — never accepts a version counter.",
248
+ ].join("\n");
249
+ function renderTicketRecoveryResult(result, json) {
250
+ if (json) {
251
+ console.log(JSON.stringify(result));
252
+ return result.ok ? 0 : 1;
253
+ }
254
+ if (result.ok) {
255
+ console.log(`Ticket ${result.ticketKey} (run ${result.epicRunId}): unparked — status: ${result.status}.`);
256
+ return 0;
257
+ }
258
+ if (result.kind === "ticket-not-found") {
259
+ console.error(`Ticket ${result.ticketKey}: not found in run ${result.epicRunId}.`);
260
+ return 1;
261
+ }
262
+ if (result.kind === "concurrent-change-exhausted") {
263
+ console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — it kept ` +
264
+ "changing concurrently. Re-check the run/ticket status and try again.");
265
+ return 1;
266
+ }
267
+ console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — ${result.message}.`);
268
+ return 1;
269
+ }
270
+ export async function runUnparkCommand(argv) {
271
+ try {
272
+ const parsed = parseTicketRecoveryArgs(argv);
273
+ if (parsed.help) {
274
+ console.log(UNPARK_USAGE);
275
+ return 0;
276
+ }
277
+ const access = await resolveRecoveryAccess(parsed.json);
278
+ if (!access)
279
+ return 1;
280
+ const { unparkEpicTicketWithRetry } = await import("./recovery-operations.js");
281
+ const result = await unparkEpicTicketWithRetry(access, {
282
+ epicRunId: parsed.epicRunId,
283
+ ticketKey: parsed.ticketKey,
284
+ });
285
+ return renderTicketRecoveryResult(result, parsed.json);
286
+ }
287
+ catch (error) {
288
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
289
+ return 1;
290
+ }
291
+ }
292
+ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
293
+ try {
294
+ const parsed = parseTicketRecoveryArgs(argv);
295
+ if (parsed.help) {
296
+ console.log(ADOPT_CURRENT_HEAD_USAGE);
297
+ return 0;
298
+ }
299
+ const access = await resolveRecoveryAccess(parsed.json);
300
+ if (!access)
301
+ return 1;
302
+ const { adoptCurrentHeadAndUnparkWithRetry } = await import("./recovery-operations.js");
303
+ const result = await adoptCurrentHeadAndUnparkWithRetry(access, {
304
+ epicRunId: parsed.epicRunId,
305
+ ticketKey: parsed.ticketKey,
306
+ });
307
+ return renderTicketRecoveryResult(result, parsed.json);
308
+ }
309
+ catch (error) {
310
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
311
+ return 1;
312
+ }
313
+ }