@bridge_gpt/mcp-server 0.2.9 → 0.2.10

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.
package/README.md CHANGED
@@ -6,7 +6,58 @@ MCP server for [Bridge API](https://bridgegpt-api.com) — exposes Jira integrat
6
6
 
7
7
  ## Getting Started
8
8
 
9
- ### 1. Install the Package
9
+ ### Quick start (one command)
10
+
11
+ From your **project root**, run:
12
+
13
+ ```bash
14
+ npx -y @bridge_gpt/mcp-server@latest install-bridge
15
+ ```
16
+
17
+ `install-bridge` collapses the whole setup into a single command. It:
18
+
19
+ 1. **Scaffolds** the project (the same artifacts `--init` writes: slash commands,
20
+ agents, `.bridge/pipelines/`, and secret-free MCP config placeholders).
21
+ 2. **Writes the per-host MCP config** (`.mcp.json` / `.cursor/mcp.json` /
22
+ `.vscode/mcp.json`) with your real `BAPI_REPO_NAME` / `BAPI_API_KEY` /
23
+ `BAPI_BASE_URL` / `BAPI_DOCS_DIR`, preserving any unrelated servers. The
24
+ launcher it writes is pinned to the exact installed version so `npx` never
25
+ silently reuses a stale local copy. (Windsurf and Codex are global configs it
26
+ can't safely write — it prints copy-paste instructions for those.)
27
+ 3. **Verifies connectivity** against the Bridge API before persisting anything.
28
+ 4. **Persists your key** to the user-scoped credential store
29
+ (`~/.config/bridge/credentials.json`, target `bapi:<repo>`) so shell-spawned
30
+ tooling (e.g. `start-tickets`) can resolve it.
31
+ 5. **Opens a fresh agent session** that runs `/install-bridge` (to derive the
32
+ remaining config fields from your codebase) and then `/learn-repository`.
33
+
34
+ The only inputs are an **API key** and a **repo name** (everything else is
35
+ derived). Resolution order:
36
+
37
+ - **API key:** `--api-key <key>` → `BAPI_API_KEY` env → an interactive (no-echo)
38
+ prompt. Generate one first on the Bridge API web UI **Security** page (see
39
+ [Generate an API Key](#2-generate-an-api-key)); the command consumes a key, it
40
+ never mints one. The key is **never printed or logged**.
41
+ - **Repo name:** `--repo <name>` → `BAPI_REPO_NAME` env → an inferred default you
42
+ confirm interactively. It MUST match the server-side repository registration.
43
+
44
+ Useful flags:
45
+
46
+ - `--dry-run` — preview every step (scaffold targets, config files and keys with
47
+ the key value **redacted**, the ping target, the credential store target, and
48
+ the exact agent spawn command) without writing, pinging, or spawning anything.
49
+ - `--force` — overwrite an existing real `BAPI_API_KEY` in a host config without
50
+ prompting (re-running is otherwise non-destructive).
51
+ - `--agent claude|cursor-agent` — which agent to launch for the agentic remainder
52
+ (default `claude`).
53
+
54
+ That's it — once `install-bridge` finishes you're connected. If you prefer to do
55
+ it by hand (or just want to understand each step), the manual flow below does the
56
+ same thing.
57
+
58
+ ### Manual Setup (Alternative)
59
+
60
+ #### 1. Install the Package
10
61
 
11
62
  From your **project root**, install the MCP server and scaffold slash commands:
12
63
 
@@ -23,7 +74,7 @@ npx -y @bridge_gpt/mcp-server --init
23
74
 
24
75
  Re-run `--init` after upgrading the package to get updated commands.
25
76
 
26
- ### 2. Generate an API Key
77
+ #### 2. Generate an API Key
27
78
 
28
79
  1. Log in to [Bridge API](https://bridgegpt-api.com) and navigate to your project's **Security** page
29
80
  2. Click **Create New Key**
@@ -31,7 +82,7 @@ Re-run `--init` after upgrading the package to get updated commands.
31
82
  4. Click **Create Key**
32
83
  5. **Copy the key immediately** — it will not be shown again
33
84
 
34
- ### 3. Configure the MCP Server
85
+ #### 3. Configure the MCP Server
35
86
 
36
87
  Add the following to your editor's MCP configuration file, pasting in the API key from step 2:
37
88
 
@@ -145,7 +196,7 @@ BAPI_DOCS_DIR = "docs/tmp"
145
196
 
146
197
  After saving the config, restart your editor or reload the MCP server connection. Verify connectivity by asking your AI assistant to call the `ping` tool.
147
198
 
148
- ### 4. First-Time Setup: Teach Bridge Your Codebase
199
+ #### 4. First-Time Setup: Teach Bridge Your Codebase
149
200
 
150
201
  If you're the first person to install Bridge API on your project, run the `/learn-repository` slash command after completing setup. This analyzes your codebase's architecture, testing patterns, code review standards, and documentation conventions, then uploads the findings to Bridge API. This gives Bridge the context it needs to generate implementation plans, ticket critiques, and code reviews that are consistent with your project's actual architecture and conventions.
151
202
 
@@ -540,6 +591,7 @@ Reports are written to `<BAPI_DOCS_DIR>/smoke-test/REPORT-<host>-<timestamp>.md`
540
591
  | `BAPI_WORKTRUNK_BIN` | No | `wt` (`git-wt` on Windows) | Override the Worktrunk executable name/path used by `start-tickets` for nonstandard installs |
541
592
  | `BAPI_TMUX_SESSION` | No | `bridge-start-tickets` | Override the tmux session-name prefix used by `start-tickets` on Linux |
542
593
  | `BAPI_MCP_UPGRADE_ADVICE_ENABLED` | No | _(enabled)_ | MCP-local opt-out for proactively surfacing upgrade advice in pipeline recipe preambles. Set to `false`/`0`/`no`/`off`/`disabled` to suppress. Disabling it does **not** change the `/jira/ping` response or server-side upgrade computation — it only gates the recipe-preamble convention |
594
+ | `BRIDGE_MCP_PROFILE` | No | `core` | Startup-time tool registration profile. Controls which tool groups are registered when the server starts. Valid values: `core` (default — normal coding tools only), `conductor` (core + 8 conductor/event/supervisor tools), `pipeline-authoring` (core + 5 pipeline run/admin tools — `get_pipeline_recipe` is NOT gated; it stays in `core` because the recipe-driven slash commands depend on it), `full` (all tools, equivalent to the legacy unconditional registration). Unknown, blank, or malformed values fail safe to `core`. Dynamic mid-session switching via `tools/list_changed` is unsupported — the profile is resolved once at process startup. **Phase 2b note:** epic/conductor sessions launched via `start-tickets` will automatically inject `BRIDGE_MCP_PROFILE=conductor`; that injection is handled at the spawn boundary and is out of scope for this phase. |
543
595
 
544
596
  ## Worktree credentials and the `mcp-invoke` shim
545
597
 
@@ -13,7 +13,7 @@ import os from "node:os";
13
13
  import { readFile, stat } from "node:fs/promises";
14
14
  import { resolveBapiCredentials } from "../credential-store.js";
15
15
  import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
16
- import { DONE_GATE_CONFIG_FIELD, normalizePrNumber, normalizeSha } from "./git-ci-types.js";
16
+ import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
17
17
  import { ConductorValidationError } from "./errors.js";
18
18
  /** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
19
19
  export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
@@ -95,7 +95,7 @@ function conductorGetHeaders(access) {
95
95
  * non-2xx response — never including the response body, headers, or API key. The
96
96
  * timer is always cleared.
97
97
  */
98
- export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = fetch) {
98
+ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = globalThis.fetch) {
99
99
  const controller = new AbortController();
100
100
  const timer = setTimeout(() => controller.abort(), timeoutMs);
101
101
  try {
@@ -132,7 +132,7 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
132
132
  * `value` payload (or `undefined` when the envelope lacks a `value`). The field
133
133
  * name is URL-encoded; the repo scope is always included.
134
134
  */
135
- export async function fetchConductorConfigField(access, fieldName, fetchImpl = fetch) {
135
+ export async function fetchConductorConfigField(access, fieldName, fetchImpl = globalThis.fetch) {
136
136
  const url = buildConductorJiraUrl(access.baseUrl, `/config-field/${encodeURIComponent(fieldName)}`, {
137
137
  repo_name: access.repoName,
138
138
  });
@@ -142,9 +142,41 @@ export async function fetchConductorConfigField(access, fieldName, fetchImpl = f
142
142
  }
143
143
  return undefined;
144
144
  }
145
- /** Fetch the per-repo `conductor_done_gate` config value (raw, unparsed). */
146
- export function fetchDoneGateConfigField(access, fetchImpl = fetch) {
147
- return fetchConductorConfigField(access, DONE_GATE_CONFIG_FIELD, fetchImpl);
145
+ /**
146
+ * Fetch the effective supervisor setup for a repo.
147
+ *
148
+ * When ``epicKey`` is provided, hits ``GET /jira/epic-runs/runs/{epicKey}/supervisor-setup/``
149
+ * (per-epic row with project-default fallback). When omitted, hits
150
+ * ``GET /jira/epic-runs/supervisor-setup/defaults/`` (project-default only).
151
+ * Fails closed (throws ``ConductorBridgeApiError``) on any network or server error.
152
+ */
153
+ export async function fetchEffectiveSupervisorSetup(access, epicKey, fetchImpl = globalThis.fetch) {
154
+ const apiPath = epicKey
155
+ ? `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}/supervisor-setup/`
156
+ : `${EPIC_RUNS_API_PREFIX}/supervisor-setup/defaults/`;
157
+ const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
158
+ repo_name: access.repoName,
159
+ });
160
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
161
+ return parsed;
162
+ }
163
+ /**
164
+ * Fetch the effective supervisor config for a repo.
165
+ *
166
+ * When ``epicKey`` is provided, hits ``GET /jira/epic-runs/runs/{epicKey}/supervisor-config/``
167
+ * (per-epic row with project-default fallback). When omitted, hits
168
+ * ``GET /jira/epic-runs/supervisor-config/defaults/`` (project-default only).
169
+ * Fails closed (throws ``ConductorBridgeApiError``) on any network or server error.
170
+ */
171
+ export async function fetchEffectiveSupervisorConfig(access, epicKey, fetchImpl = globalThis.fetch) {
172
+ const apiPath = epicKey
173
+ ? `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}/supervisor-config/`
174
+ : `${EPIC_RUNS_API_PREFIX}/supervisor-config/defaults/`;
175
+ const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
176
+ repo_name: access.repoName,
177
+ });
178
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
179
+ return parsed;
148
180
  }
149
181
  /**
150
182
  * Poll CI checks for a commit through the existing `/poll-ci-checks` endpoint.
@@ -152,7 +184,7 @@ export function fetchDoneGateConfigField(access, fetchImpl = fetch) {
152
184
  * request when it is not a valid SHA. Returns the endpoint response verbatim —
153
185
  * the client never invents `pr_number`/`head_sha` fields.
154
186
  */
155
- export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch) {
187
+ export async function pollCiChecksForCommit(access, commitRef, fetchImpl = globalThis.fetch) {
156
188
  const sha = normalizeSha(commitRef);
157
189
  if (sha === null) {
158
190
  throw new ConductorBridgeApiError("invalid-input");
@@ -164,6 +196,26 @@ export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch
164
196
  return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
165
197
  }
166
198
  // ---------------------------------------------------------------------------
199
+ // Conductor BAPI-440: PR review status GET client
200
+ // ---------------------------------------------------------------------------
201
+ /**
202
+ * Fetch the normalized PR review status from the backend-owned review-status
203
+ * endpoint. Validates `prNumber` with {@link normalizePrNumber} before issuing
204
+ * the request. Returns the endpoint response verbatim — the client never invents
205
+ * review fields. Throws a sanitized {@link ConductorBridgeApiError} on any failure
206
+ * so the producer can fail-closed without leaking body/headers/key.
207
+ */
208
+ export async function fetchPrReviewStatus(access, prNumber, fetchImpl = globalThis.fetch) {
209
+ const pr = normalizePrNumber(prNumber);
210
+ if (pr === null) {
211
+ throw new ConductorBridgeApiError("invalid-input");
212
+ }
213
+ const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/reviews/status`);
214
+ const fullUrl = new URL(url);
215
+ fullUrl.searchParams.set("repo_name", access.repoName);
216
+ return fetchConductorJsonWithTimeout(fullUrl.toString(), conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
217
+ }
218
+ // ---------------------------------------------------------------------------
167
219
  // Conductor C6 (BAPI-398): protected VCS merge POST client
168
220
  // ---------------------------------------------------------------------------
169
221
  /**
@@ -181,19 +233,20 @@ function conductorPostHeaders(access) {
181
233
  return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
182
234
  }
183
235
  /**
184
- * POST JSON with an `AbortController` timeout, mirroring
185
- * {@link fetchConductorJsonWithTimeout}. Throws a sanitized
236
+ * Send a JSON request body with the given HTTP method and an `AbortController`
237
+ * timeout, mirroring {@link fetchConductorJsonWithTimeout}. Throws a sanitized
186
238
  * {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
187
239
  * non-2xx response — never including the response body, headers, API key, or
188
- * request body in the error. The timer is always cleared.
240
+ * request body in the error. The timer is always cleared. Shared core for the
241
+ * POST and PATCH wrappers below.
189
242
  */
190
- export async function fetchConductorJsonPostWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
243
+ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
191
244
  const controller = new AbortController();
192
245
  const timer = setTimeout(() => controller.abort(), timeoutMs);
193
246
  try {
194
247
  let resp;
195
248
  try {
196
- resp = await fetchImpl(url, { method: "POST", headers, body, signal: controller.signal });
249
+ resp = await fetchImpl(url, { method, headers, body, signal: controller.signal });
197
250
  }
198
251
  catch {
199
252
  throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
@@ -218,6 +271,29 @@ export async function fetchConductorJsonPostWithTimeout(url, headers, body, time
218
271
  clearTimeout(timer);
219
272
  }
220
273
  }
274
+ /**
275
+ * POST JSON with an `AbortController` timeout. Thin wrapper over
276
+ * {@link fetchConductorJsonWithMethodAndTimeout}.
277
+ */
278
+ export async function fetchConductorJsonPostWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
279
+ return fetchConductorJsonWithMethodAndTimeout("POST", url, headers, body, timeoutMs, fetchImpl);
280
+ }
281
+ /**
282
+ * PATCH JSON with an `AbortController` timeout. Thin wrapper over
283
+ * {@link fetchConductorJsonWithMethodAndTimeout}. Used by the per-ticket CAS
284
+ * status endpoint, which the backend declares as PATCH.
285
+ */
286
+ export async function fetchConductorJsonPatchWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
287
+ return fetchConductorJsonWithMethodAndTimeout("PATCH", url, headers, body, timeoutMs, fetchImpl);
288
+ }
289
+ /**
290
+ * PUT JSON with an `AbortController` timeout. Thin wrapper over
291
+ * {@link fetchConductorJsonWithMethodAndTimeout}. Used by endpoints that the
292
+ * backend declares as PUT (e.g. the Jira status transition endpoint).
293
+ */
294
+ export async function fetchConductorJsonPutWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
295
+ return fetchConductorJsonWithMethodAndTimeout("PUT", url, headers, body, timeoutMs, fetchImpl);
296
+ }
221
297
  /**
222
298
  * Call the protected `POST /vcs/pull-requests/{pr_number}/merge` endpoint. The PR
223
299
  * number lives ONLY in the path; the API key travels ONLY in headers; the body
@@ -225,7 +301,7 @@ export async function fetchConductorJsonPostWithTimeout(url, headers, body, time
225
301
  * before the request is issued. Throws a sanitized {@link ConductorBridgeApiError}
226
302
  * on any failure.
227
303
  */
228
- export async function mergePullRequestForGate(access, request, fetchImpl = fetch) {
304
+ export async function mergePullRequestForGate(access, request, fetchImpl = globalThis.fetch) {
229
305
  const pr = normalizePrNumber(request.pr_number);
230
306
  const sha = normalizeSha(request.expected_head_sha);
231
307
  if (pr === null || sha === null || !request.action_key || !request.repo_name) {
@@ -244,6 +320,51 @@ export async function mergePullRequestForGate(access, request, fetchImpl = fetch
244
320
  const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
245
321
  return parsed;
246
322
  }
323
+ /**
324
+ * Call the protected `POST /vcs/pull-requests/{pr_number}/remediate` endpoint.
325
+ * The PR number lives ONLY in the path; the API key travels ONLY in headers; the
326
+ * body carries no branch name or provider token. A 409 (idempotency-key replay)
327
+ * is caught and returned as `{ ok: true, conflict: true }`; all other failures
328
+ * throw a sanitized {@link ConductorBridgeApiError}.
329
+ */
330
+ export async function remediateEpicTicket(access, request, fetchImpl = globalThis.fetch) {
331
+ const pr = normalizePrNumber(request.pr_number);
332
+ if (pr === null ||
333
+ !request.epic_run_id ||
334
+ !request.ticket_key ||
335
+ !request.head_sha ||
336
+ !request.idempotency_key) {
337
+ throw new ConductorBridgeApiError("invalid-input");
338
+ }
339
+ requireNonNegativeSafeInteger(request.expected_row_version);
340
+ if (request.attempt_kind !== "nudge" && request.attempt_kind !== "redispatch") {
341
+ throw new ConductorBridgeApiError("invalid-input");
342
+ }
343
+ const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/remediate`);
344
+ // The body omits pr_number (it is in the path) and never includes a branch name
345
+ // or provider token; extra fields would be rejected server-side (extra=forbid).
346
+ const body = JSON.stringify({
347
+ repo_name: access.repoName,
348
+ epic_run_id: request.epic_run_id,
349
+ ticket_key: request.ticket_key,
350
+ expected_row_version: request.expected_row_version,
351
+ head_sha: request.head_sha,
352
+ idempotency_key: request.idempotency_key,
353
+ attempt_kind: request.attempt_kind,
354
+ ...(request.reason ? { reason: request.reason } : {}),
355
+ });
356
+ try {
357
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
358
+ return { ok: true, conflict: false, response: parsed };
359
+ }
360
+ catch (err) {
361
+ if (err instanceof ConductorBridgeApiError && err.kind === "http" && err.status === 409) {
362
+ // Idempotency-key replay: the attempt was already recorded on a prior tick.
363
+ return { ok: true, conflict: true };
364
+ }
365
+ throw err;
366
+ }
367
+ }
247
368
  // ---------------------------------------------------------------------------
248
369
  // Local boundary validators (reject before any fetch call)
249
370
  // ---------------------------------------------------------------------------
@@ -268,7 +389,7 @@ function requireNoSlashPathSegment(value) {
268
389
  }
269
390
  }
270
391
  const EPIC_TICKET_STATUS_VALUES = [
271
- "planned", "ready", "dispatched", "running", "blocked", "abandoned", "done",
392
+ "planned", "ready", "dispatched", "running", "blocked", "abandoned", "done", "ready_for_review",
272
393
  ];
273
394
  function requireEpicTicketStatusValue(value) {
274
395
  if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
@@ -299,15 +420,21 @@ function epicDispatchTransitionApiPath(dispatchKey, nextStatus) {
299
420
  * Build the canonical dispatch idempotency key in lock-step with the server-side
300
421
  * `build_dispatch_key` Python helper.
301
422
  *
302
- * Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`.
423
+ * Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`. For a remediation
424
+ * re-dispatch (BAPI-441), pass `attempt > 0` to append an `:r{attempt}` suffix
425
+ * (e.g. `dispatch:BAPI-405:BAPI-441:3:r2`) so the re-dispatch claims a *distinct*
426
+ * key from the original epic dispatch and is not deduped against it. `attempt`
427
+ * defaults to 0, which yields the original (un-suffixed) key for normal dispatch.
303
428
  *
304
429
  * // TODO(epic-run-store): the epic component maps to the server-side epic_run_id component used by build_dispatch_key; confirm naming if the sibling renames it at integration.
305
430
  */
306
- export function buildEpicDispatchKey(epicKey, ticketKey, planVersion) {
431
+ export function buildEpicDispatchKey(epicKey, ticketKey, planVersion, attempt = 0) {
307
432
  requireNonEmptyString(epicKey);
308
433
  requireNonEmptyString(ticketKey);
309
434
  requireNonNegativeSafeInteger(planVersion);
310
- return `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
435
+ requireNonNegativeSafeInteger(attempt);
436
+ const base = `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
437
+ return attempt > 0 ? `${base}:r${attempt}` : base;
311
438
  }
312
439
  // ---------------------------------------------------------------------------
313
440
  // Epic supervision lease claim/renew
@@ -334,7 +461,7 @@ function parseEpicSupervisionLeaseResult(parsed) {
334
461
  * acquired/renewed from held-by-other and terminal so the control loop can decide
335
462
  * whether to act or exit as an observer. Validates inputs before the request.
336
463
  */
337
- export async function claimEpicSupervisionLease(access, request, fetchImpl = fetch) {
464
+ export async function claimEpicSupervisionLease(access, request, fetchImpl = globalThis.fetch) {
338
465
  requireNonEmptyString(request.epicKey);
339
466
  requireNonEmptyString(request.leaseOwner);
340
467
  requirePositiveSafeInteger(request.ttlSeconds);
@@ -355,12 +482,31 @@ export async function claimEpicSupervisionLease(access, request, fetchImpl = fet
355
482
  * Returns the server payload verbatim — no ready-set computation, plan-hash
356
483
  * assertion, or derived control-loop fields are added.
357
484
  */
358
- export async function fetchEpicRunState(access, epicKey, fetchImpl = fetch) {
485
+ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.fetch) {
359
486
  requireNonEmptyString(epicKey);
360
487
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/state`, { repo_name: access.repoName });
361
488
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
362
489
  return parsed;
363
490
  }
491
+ /**
492
+ * GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
493
+ * list of active epic runs. Caps at 20 results — enough for typical deployments
494
+ * and prevents runaway iteration in the producer resolution seam.
495
+ */
496
+ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch) {
497
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`, {
498
+ repo_name: access.repoName,
499
+ status: "active",
500
+ limit: "20",
501
+ });
502
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
503
+ if (parsed && typeof parsed === "object") {
504
+ const runs = parsed.runs;
505
+ if (Array.isArray(runs))
506
+ return runs;
507
+ }
508
+ return [];
509
+ }
364
510
  // ---------------------------------------------------------------------------
365
511
  // Per-ticket CAS status advancement
366
512
  // ---------------------------------------------------------------------------
@@ -393,13 +539,14 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
393
539
  throw new ConductorBridgeApiError("server");
394
540
  }
395
541
  /**
396
- * POST to the per-ticket CAS status endpoint. Surfaces CAS conflicts as a distinct
397
- * non-throwing outcome so the control loop can re-read rather than crashing.
542
+ * PATCH the per-ticket CAS status endpoint
543
+ * (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
544
+ * conflicts as a distinct non-throwing outcome where the backend returns one;
545
+ * the current backend instead raises a 400 VALIDATION on a stale row_version,
546
+ * which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
398
547
  * Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
399
- *
400
- * // TODO(epic-run-store): ticket requires POST but sibling currently declares PATCH /runs/{epic_run_id}/tickets/{ticket_key}; reconcile HTTP method at integration.
401
548
  */
402
- export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch) {
549
+ export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
403
550
  requireNonEmptyString(request.epicKey);
404
551
  requireNonEmptyString(request.ticketKey);
405
552
  requireNonNegativeSafeInteger(request.expectedRowVersion);
@@ -408,7 +555,10 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
408
555
  if (request.dispatchRunId !== undefined) {
409
556
  requireNonEmptyString(request.dispatchRunId);
410
557
  }
411
- // TODO(epic-run-store): ticket requires POST; sibling is currently PATCH — reconcile at integration
558
+ // The backend declares this endpoint as PATCH /runs/{epic_run_id}/tickets/{ticket_key}
559
+ // (api/routes/epic_runs.py:489). The body matches PatchEpicTicketStatusRequest:
560
+ // repo_name + expected_row_version are required; status/plan_version/dispatch_run_id
561
+ // are optional and accepted (the model does not forbid extras).
412
562
  const CAS_ENDPOINT_PATH = `${epicRunApiPath(request.epicKey)}/tickets/${encodeURIComponent(request.ticketKey)}`;
413
563
  const url = buildConductorJiraUrl(access.baseUrl, CAS_ENDPOINT_PATH);
414
564
  const body = JSON.stringify({
@@ -418,7 +568,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
418
568
  expected_row_version: request.expectedRowVersion,
419
569
  ...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
420
570
  });
421
- const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
571
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
422
572
  return parseAdvanceEpicTicketStatusResult(parsed);
423
573
  }
424
574
  /**
@@ -427,7 +577,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
427
577
  * ticks and re-plans is safe. Throws a sanitized {@link ConductorBridgeApiError}
428
578
  * on any transport/auth/server error.
429
579
  */
430
- export async function createEpicTicketStatus(access, request, fetchImpl = fetch) {
580
+ export async function createEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
431
581
  requireNonEmptyString(request.epicKey);
432
582
  requireNonEmptyString(request.ticketKey);
433
583
  requireEpicTicketStatusValue(request.status);
@@ -486,13 +636,13 @@ function parseEpicDispatchResult(parsed) {
486
636
  * outcome so a crashed-then-retried tick never double-dispatches. The dispatch key
487
637
  * is composed exclusively via {@link buildEpicDispatchKey}.
488
638
  */
489
- export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
639
+ export async function recordEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
490
640
  requireNonEmptyString(request.epicKey);
491
641
  requireNonEmptyString(request.ticketKey);
492
642
  requireNonEmptyString(request.leaseOwner);
493
643
  requireNonNegativeSafeInteger(request.planVersion);
494
644
  requirePositiveSafeInteger(request.ttlSeconds);
495
- const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion);
645
+ const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion, request.attempt ?? 0);
496
646
  const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/dispatch/claim`);
497
647
  const body = JSON.stringify({
498
648
  repo_name: access.repoName,
@@ -515,7 +665,7 @@ export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
515
665
  * Dispatch keys containing `/` are rejected before the request because transition
516
666
  * endpoints embed the key as a URL path segment.
517
667
  */
518
- export async function transitionEpicDispatch(access, request, fetchImpl = fetch) {
668
+ export async function transitionEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
519
669
  requireNonEmptyString(request.dispatchKey);
520
670
  requireNoSlashPathSegment(request.dispatchKey);
521
671
  requireEpicDispatchTransitionStatus(request.nextStatus);
@@ -537,7 +687,7 @@ export async function transitionEpicDispatch(access, request, fetchImpl = fetch)
537
687
  * in the `X-API-Key` header, never in the URL. Throws a sanitized
538
688
  * {@link ConductorBridgeApiError} on any transport/auth/server error.
539
689
  */
540
- export async function storeEpicPlan(access, request, fetchImpl = fetch) {
690
+ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetch) {
541
691
  requireNonEmptyString(request.epicKey);
542
692
  requirePositiveSafeInteger(request.planVersion);
543
693
  requireNonEmptyString(request.planHash);
@@ -562,7 +712,7 @@ export async function storeEpicPlan(access, request, fetchImpl = fetch) {
562
712
  * monotonic constraint violation). Other errors throw a sanitized
563
713
  * {@link ConductorBridgeApiError}.
564
714
  */
565
- export async function approveEpicPlan(access, request, fetchImpl = fetch) {
715
+ export async function approveEpicPlan(access, request, fetchImpl = globalThis.fetch) {
566
716
  requireNonEmptyString(request.epicKey);
567
717
  requirePositiveSafeInteger(request.planVersion);
568
718
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/approve-plan`);
@@ -589,7 +739,7 @@ export async function approveEpicPlan(access, request, fetchImpl = fetch) {
589
739
  * Returns the response as a {@link GetEpicPlanResponse}. The API key travels
590
740
  * ONLY in the `X-API-Key` header, never in the URL.
591
741
  */
592
- export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetch) {
742
+ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = globalThis.fetch) {
593
743
  requireNonEmptyString(epicKey);
594
744
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/plan`, { repo_name: access.repoName, plan_version: String(planVersion) });
595
745
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
@@ -604,7 +754,7 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetc
604
754
  * holds the lock) and `"idle"` (no lock is held). Throws a sanitized
605
755
  * {@link ConductorBridgeApiError} on any transport/auth/server failure.
606
756
  */
607
- export async function fetchParseStatus(access, fetchImpl = fetch) {
757
+ export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
608
758
  const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
609
759
  repo_name: access.repoName,
610
760
  });
@@ -618,8 +768,85 @@ export async function fetchParseStatus(access, fetchImpl = fetch) {
618
768
  * safe. Returns the response envelope verbatim. Throws a sanitized
619
769
  * {@link ConductorBridgeApiError} on any transport/auth/server failure.
620
770
  */
621
- export async function triggerRepositoryParse(access, fetchImpl = fetch) {
771
+ export async function triggerRepositoryParse(access, fetchImpl = globalThis.fetch) {
622
772
  const url = buildConductorJiraUrl(access.baseUrl, "/parse-repository");
623
773
  const body = JSON.stringify({ repo_name: access.repoName });
624
774
  return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
625
775
  }
776
+ /**
777
+ * Call the protected `DELETE /vcs/pull-requests/{pr_number}/branch` endpoint.
778
+ * Treats 404-equivalent responses as idempotent success (already deleted).
779
+ * Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network
780
+ * failures.
781
+ */
782
+ export async function deletePullRequestBranch(access, prNumber, expectedHeadSha, fetchImpl = globalThis.fetch) {
783
+ const pr = normalizePrNumber(prNumber);
784
+ if (pr === null) {
785
+ throw new ConductorBridgeApiError("invalid-input");
786
+ }
787
+ const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/branch?repo_name=${encodeURIComponent(access.repoName)}&expected_head_sha=${encodeURIComponent(expectedHeadSha)}`);
788
+ const controller = new AbortController();
789
+ const timer = setTimeout(() => controller.abort(), CONDUCTOR_FETCH_TIMEOUT_MS);
790
+ try {
791
+ let resp;
792
+ try {
793
+ resp = await fetchImpl(url, {
794
+ method: "DELETE",
795
+ headers: { "X-API-Key": access.apiKey },
796
+ body: "",
797
+ signal: controller.signal,
798
+ });
799
+ }
800
+ catch {
801
+ throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
802
+ }
803
+ // 404 from the provider → branch already gone → idempotent success
804
+ if (resp.status === 404) {
805
+ return { deleted: false, branch: null, reason: "not_found" };
806
+ }
807
+ if (!resp.ok) {
808
+ if (resp.status === 401 || resp.status === 403) {
809
+ throw new ConductorBridgeApiError("unauthorized", resp.status);
810
+ }
811
+ if (resp.status >= 500) {
812
+ throw new ConductorBridgeApiError("server", resp.status);
813
+ }
814
+ throw new ConductorBridgeApiError("http", resp.status);
815
+ }
816
+ try {
817
+ return (await resp.json());
818
+ }
819
+ catch {
820
+ throw new ConductorBridgeApiError("network");
821
+ }
822
+ }
823
+ finally {
824
+ clearTimeout(timer);
825
+ }
826
+ }
827
+ /**
828
+ * Call `PUT /jira/tickets/{ticketNumber}/jira-status` to transition the ticket's
829
+ * Jira status on truly-done. Uses `target_status: "auto"` for server-side resolution.
830
+ * Treats HTTP 400 (no matching transition / already in target) as a benign skip.
831
+ * Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network failures.
832
+ */
833
+ export async function transitionJiraStatus(access, ticketNumber, targetStatus = "auto", fetchImpl = globalThis.fetch) {
834
+ if (!ticketNumber) {
835
+ throw new ConductorBridgeApiError("invalid-input");
836
+ }
837
+ // repo_name travels in the JSON body only (TransitionJiraStatusRequest) — the
838
+ // backend ignores any query param, so don't duplicate it in the URL.
839
+ const url = buildConductorJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticketNumber)}/jira-status`);
840
+ const body = JSON.stringify({ repo_name: access.repoName, target_status: targetStatus });
841
+ try {
842
+ await fetchConductorJsonPutWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
843
+ return { status: "transitioned" };
844
+ }
845
+ catch (err) {
846
+ // HTTP 400 = no matching transition / already in target → benign skip.
847
+ if (err instanceof ConductorBridgeApiError && err.kind === "http" && err.status === 400) {
848
+ return { status: "skipped" };
849
+ }
850
+ throw err;
851
+ }
852
+ }
@@ -528,7 +528,7 @@ export async function runDoctorCommand(argv) {
528
528
  // scheduleDeps omitted: buildConductorDoctorReport lazily loads schedule-run.
529
529
  const report = await buildConductorDoctorReport({});
530
530
  if (bools.has("--json")) {
531
- console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick }));
531
+ console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick, mcp_profile: report.mcp_profile }));
532
532
  return 0;
533
533
  }
534
534
  console.log(formatConductorDoctorReport(report));
@@ -662,6 +662,24 @@ export function parseEpicTickArgs(argv) {
662
662
  const leaseTtlSeconds = parsePositiveIntFlag(values, "--lease-ttl-seconds");
663
663
  return { epicKey: epicKeyRaw.trim(), scheduledAt, leaseTtlSeconds, help: false };
664
664
  }
665
+ /**
666
+ * Default the PreToolUse hook ON for epic-dispatched workers so the supervisor
667
+ * gets worker-liveness (`tool.intent`) signals without the operator having to
668
+ * export `BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE=1` (BAPI-441/A1 finding).
669
+ *
670
+ * Both the child-env copy and the hook registration key off this SAME
671
+ * parent-process env flag, read at dispatch time, so setting it once at the
672
+ * epic-tick boundary covers the whole tick. Only defaults when the flag is
673
+ * UNSET — an explicit "0"/"" still disables it, because `isConductorFlagEnabled`
674
+ * treats those as false. Mutates the supplied env in place (defaults to
675
+ * `process.env`) and returns it.
676
+ */
677
+ export function applyEpicTickPreToolUseDefault(env = process.env) {
678
+ if (env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE === undefined) {
679
+ env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE = "1";
680
+ }
681
+ return env;
682
+ }
665
683
  /**
666
684
  * Run the `epic-tick` command. Lazily imports the epic runtime so a plain
667
685
  * `conductor doctor` / `emit-event` invocation never eagerly resolves the
@@ -673,6 +691,9 @@ export async function runEpicTickCommand(argv) {
673
691
  console.log(getConductorUsage());
674
692
  return 0;
675
693
  }
694
+ // Enable supervisor worker-liveness by default for the whole tick (see the
695
+ // helper's doc comment); set before runEpicTick dispatches any worker.
696
+ applyEpicTickPreToolUseDefault();
676
697
  // `runEpicTick` is imported LAZILY so the store/supervisor graph is not
677
698
  // evaluated for other conductor CLI commands that never need it.
678
699
  const { runEpicTick, buildProductionEpicRuntimeDeps } = await import("./epic-runtime.js");