@bridge_gpt/mcp-server 0.2.9 → 0.2.12

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 (43) hide show
  1. package/README.md +59 -7
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +263 -35
  4. package/build/conductor/cli.js +38 -17
  5. package/build/conductor/doctor.js +35 -2
  6. package/build/conductor/done-gate.js +301 -58
  7. package/build/conductor/epic-reconcile.js +318 -4
  8. package/build/conductor/epic-runtime.js +382 -18
  9. package/build/conductor/epic-state.js +188 -15
  10. package/build/conductor/errors.js +12 -0
  11. package/build/conductor/git-ci-types.js +16 -0
  12. package/build/conductor/git-producer.js +4 -4
  13. package/build/conductor/merge-ledger.js +7 -7
  14. package/build/conductor/pr-ci-producer.js +118 -19
  15. package/build/conductor/pr-review-producer.js +116 -0
  16. package/build/conductor/producer-ledger.js +5 -5
  17. package/build/conductor/spec-review-producer.js +88 -0
  18. package/build/conductor/store.js +105 -26
  19. package/build/conductor/supervisor-ledger.js +2 -2
  20. package/build/conductor/supervisor-merge.js +5 -5
  21. package/build/conductor/supervisor-message-relay.js +32 -1
  22. package/build/conductor/supervisor-runtime.js +10 -10
  23. package/build/conductor/taxonomy.js +8 -0
  24. package/build/conductor/tools.js +7 -7
  25. package/build/conductor-bin.js +12350 -19
  26. package/build/conductor-claude-hook-bin.js +167 -17
  27. package/build/decision-page-schema.js +26 -0
  28. package/build/doctor.js +200 -0
  29. package/build/index.js +23696 -4351
  30. package/build/init.js +481 -0
  31. package/build/install-bridge.js +772 -0
  32. package/build/mcp-profile.js +43 -0
  33. package/build/pipelines.generated.js +70 -48
  34. package/build/readme.generated.js +1 -1
  35. package/build/start-tickets-conductor.js +1 -0
  36. package/build/start-tickets.js +186 -10
  37. package/build/upgrade-cli.js +154 -0
  38. package/build/version.generated.js +1 -1
  39. package/package.json +7 -4
  40. package/pipelines/check-ci-ticket.json +2 -2
  41. package/pipelines/implement-ticket.json +2 -2
  42. package/pipelines/learn-repository.json +84 -42
  43. package/smoke-test/SMOKE-TEST.md +11 -17
@@ -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,8 @@ 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",
393
+ "reviewing",
272
394
  ];
273
395
  function requireEpicTicketStatusValue(value) {
274
396
  if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
@@ -299,15 +421,21 @@ function epicDispatchTransitionApiPath(dispatchKey, nextStatus) {
299
421
  * Build the canonical dispatch idempotency key in lock-step with the server-side
300
422
  * `build_dispatch_key` Python helper.
301
423
  *
302
- * Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`.
424
+ * Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`. For a remediation
425
+ * re-dispatch (BAPI-441), pass `attempt > 0` to append an `:r{attempt}` suffix
426
+ * (e.g. `dispatch:BAPI-405:BAPI-441:3:r2`) so the re-dispatch claims a *distinct*
427
+ * key from the original epic dispatch and is not deduped against it. `attempt`
428
+ * defaults to 0, which yields the original (un-suffixed) key for normal dispatch.
303
429
  *
304
430
  * // 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
431
  */
306
- export function buildEpicDispatchKey(epicKey, ticketKey, planVersion) {
432
+ export function buildEpicDispatchKey(epicKey, ticketKey, planVersion, attempt = 0) {
307
433
  requireNonEmptyString(epicKey);
308
434
  requireNonEmptyString(ticketKey);
309
435
  requireNonNegativeSafeInteger(planVersion);
310
- return `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
436
+ requireNonNegativeSafeInteger(attempt);
437
+ const base = `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
438
+ return attempt > 0 ? `${base}:r${attempt}` : base;
311
439
  }
312
440
  // ---------------------------------------------------------------------------
313
441
  // Epic supervision lease claim/renew
@@ -334,7 +462,7 @@ function parseEpicSupervisionLeaseResult(parsed) {
334
462
  * acquired/renewed from held-by-other and terminal so the control loop can decide
335
463
  * whether to act or exit as an observer. Validates inputs before the request.
336
464
  */
337
- export async function claimEpicSupervisionLease(access, request, fetchImpl = fetch) {
465
+ export async function claimEpicSupervisionLease(access, request, fetchImpl = globalThis.fetch) {
338
466
  requireNonEmptyString(request.epicKey);
339
467
  requireNonEmptyString(request.leaseOwner);
340
468
  requirePositiveSafeInteger(request.ttlSeconds);
@@ -355,12 +483,31 @@ export async function claimEpicSupervisionLease(access, request, fetchImpl = fet
355
483
  * Returns the server payload verbatim — no ready-set computation, plan-hash
356
484
  * assertion, or derived control-loop fields are added.
357
485
  */
358
- export async function fetchEpicRunState(access, epicKey, fetchImpl = fetch) {
486
+ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.fetch) {
359
487
  requireNonEmptyString(epicKey);
360
488
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/state`, { repo_name: access.repoName });
361
489
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
362
490
  return parsed;
363
491
  }
492
+ /**
493
+ * GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
494
+ * list of active epic runs. Caps at 20 results — enough for typical deployments
495
+ * and prevents runaway iteration in the producer resolution seam.
496
+ */
497
+ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch) {
498
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`, {
499
+ repo_name: access.repoName,
500
+ status: "active",
501
+ limit: "20",
502
+ });
503
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
504
+ if (parsed && typeof parsed === "object") {
505
+ const runs = parsed.runs;
506
+ if (Array.isArray(runs))
507
+ return runs;
508
+ }
509
+ return [];
510
+ }
364
511
  // ---------------------------------------------------------------------------
365
512
  // Per-ticket CAS status advancement
366
513
  // ---------------------------------------------------------------------------
@@ -393,13 +540,14 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
393
540
  throw new ConductorBridgeApiError("server");
394
541
  }
395
542
  /**
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.
543
+ * PATCH the per-ticket CAS status endpoint
544
+ * (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
545
+ * conflicts as a distinct non-throwing outcome where the backend returns one;
546
+ * the current backend instead raises a 400 VALIDATION on a stale row_version,
547
+ * which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
398
548
  * 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
549
  */
402
- export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch) {
550
+ export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
403
551
  requireNonEmptyString(request.epicKey);
404
552
  requireNonEmptyString(request.ticketKey);
405
553
  requireNonNegativeSafeInteger(request.expectedRowVersion);
@@ -408,7 +556,10 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
408
556
  if (request.dispatchRunId !== undefined) {
409
557
  requireNonEmptyString(request.dispatchRunId);
410
558
  }
411
- // TODO(epic-run-store): ticket requires POST; sibling is currently PATCH — reconcile at integration
559
+ // The backend declares this endpoint as PATCH /runs/{epic_run_id}/tickets/{ticket_key}
560
+ // (api/routes/epic_runs.py:489). The body matches PatchEpicTicketStatusRequest:
561
+ // repo_name + expected_row_version are required; status/plan_version/dispatch_run_id
562
+ // are optional and accepted (the model does not forbid extras).
412
563
  const CAS_ENDPOINT_PATH = `${epicRunApiPath(request.epicKey)}/tickets/${encodeURIComponent(request.ticketKey)}`;
413
564
  const url = buildConductorJiraUrl(access.baseUrl, CAS_ENDPOINT_PATH);
414
565
  const body = JSON.stringify({
@@ -418,7 +569,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
418
569
  expected_row_version: request.expectedRowVersion,
419
570
  ...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
420
571
  });
421
- const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
572
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
422
573
  return parseAdvanceEpicTicketStatusResult(parsed);
423
574
  }
424
575
  /**
@@ -427,7 +578,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
427
578
  * ticks and re-plans is safe. Throws a sanitized {@link ConductorBridgeApiError}
428
579
  * on any transport/auth/server error.
429
580
  */
430
- export async function createEpicTicketStatus(access, request, fetchImpl = fetch) {
581
+ export async function createEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
431
582
  requireNonEmptyString(request.epicKey);
432
583
  requireNonEmptyString(request.ticketKey);
433
584
  requireEpicTicketStatusValue(request.status);
@@ -486,13 +637,13 @@ function parseEpicDispatchResult(parsed) {
486
637
  * outcome so a crashed-then-retried tick never double-dispatches. The dispatch key
487
638
  * is composed exclusively via {@link buildEpicDispatchKey}.
488
639
  */
489
- export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
640
+ export async function recordEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
490
641
  requireNonEmptyString(request.epicKey);
491
642
  requireNonEmptyString(request.ticketKey);
492
643
  requireNonEmptyString(request.leaseOwner);
493
644
  requireNonNegativeSafeInteger(request.planVersion);
494
645
  requirePositiveSafeInteger(request.ttlSeconds);
495
- const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion);
646
+ const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion, request.attempt ?? 0) + (request.reviewRole ? ":review" : "");
496
647
  const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/dispatch/claim`);
497
648
  const body = JSON.stringify({
498
649
  repo_name: access.repoName,
@@ -515,7 +666,7 @@ export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
515
666
  * Dispatch keys containing `/` are rejected before the request because transition
516
667
  * endpoints embed the key as a URL path segment.
517
668
  */
518
- export async function transitionEpicDispatch(access, request, fetchImpl = fetch) {
669
+ export async function transitionEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
519
670
  requireNonEmptyString(request.dispatchKey);
520
671
  requireNoSlashPathSegment(request.dispatchKey);
521
672
  requireEpicDispatchTransitionStatus(request.nextStatus);
@@ -537,7 +688,7 @@ export async function transitionEpicDispatch(access, request, fetchImpl = fetch)
537
688
  * in the `X-API-Key` header, never in the URL. Throws a sanitized
538
689
  * {@link ConductorBridgeApiError} on any transport/auth/server error.
539
690
  */
540
- export async function storeEpicPlan(access, request, fetchImpl = fetch) {
691
+ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetch) {
541
692
  requireNonEmptyString(request.epicKey);
542
693
  requirePositiveSafeInteger(request.planVersion);
543
694
  requireNonEmptyString(request.planHash);
@@ -562,7 +713,7 @@ export async function storeEpicPlan(access, request, fetchImpl = fetch) {
562
713
  * monotonic constraint violation). Other errors throw a sanitized
563
714
  * {@link ConductorBridgeApiError}.
564
715
  */
565
- export async function approveEpicPlan(access, request, fetchImpl = fetch) {
716
+ export async function approveEpicPlan(access, request, fetchImpl = globalThis.fetch) {
566
717
  requireNonEmptyString(request.epicKey);
567
718
  requirePositiveSafeInteger(request.planVersion);
568
719
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/approve-plan`);
@@ -589,7 +740,7 @@ export async function approveEpicPlan(access, request, fetchImpl = fetch) {
589
740
  * Returns the response as a {@link GetEpicPlanResponse}. The API key travels
590
741
  * ONLY in the `X-API-Key` header, never in the URL.
591
742
  */
592
- export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetch) {
743
+ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = globalThis.fetch) {
593
744
  requireNonEmptyString(epicKey);
594
745
  const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/plan`, { repo_name: access.repoName, plan_version: String(planVersion) });
595
746
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
@@ -604,7 +755,7 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetc
604
755
  * holds the lock) and `"idle"` (no lock is held). Throws a sanitized
605
756
  * {@link ConductorBridgeApiError} on any transport/auth/server failure.
606
757
  */
607
- export async function fetchParseStatus(access, fetchImpl = fetch) {
758
+ export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
608
759
  const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
609
760
  repo_name: access.repoName,
610
761
  });
@@ -618,8 +769,85 @@ export async function fetchParseStatus(access, fetchImpl = fetch) {
618
769
  * safe. Returns the response envelope verbatim. Throws a sanitized
619
770
  * {@link ConductorBridgeApiError} on any transport/auth/server failure.
620
771
  */
621
- export async function triggerRepositoryParse(access, fetchImpl = fetch) {
772
+ export async function triggerRepositoryParse(access, fetchImpl = globalThis.fetch) {
622
773
  const url = buildConductorJiraUrl(access.baseUrl, "/parse-repository");
623
774
  const body = JSON.stringify({ repo_name: access.repoName });
624
775
  return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
625
776
  }
777
+ /**
778
+ * Call the protected `DELETE /vcs/pull-requests/{pr_number}/branch` endpoint.
779
+ * Treats 404-equivalent responses as idempotent success (already deleted).
780
+ * Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network
781
+ * failures.
782
+ */
783
+ export async function deletePullRequestBranch(access, prNumber, expectedHeadSha, fetchImpl = globalThis.fetch) {
784
+ const pr = normalizePrNumber(prNumber);
785
+ if (pr === null) {
786
+ throw new ConductorBridgeApiError("invalid-input");
787
+ }
788
+ const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/branch?repo_name=${encodeURIComponent(access.repoName)}&expected_head_sha=${encodeURIComponent(expectedHeadSha)}`);
789
+ const controller = new AbortController();
790
+ const timer = setTimeout(() => controller.abort(), CONDUCTOR_FETCH_TIMEOUT_MS);
791
+ try {
792
+ let resp;
793
+ try {
794
+ resp = await fetchImpl(url, {
795
+ method: "DELETE",
796
+ headers: { "X-API-Key": access.apiKey },
797
+ body: "",
798
+ signal: controller.signal,
799
+ });
800
+ }
801
+ catch {
802
+ throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
803
+ }
804
+ // 404 from the provider → branch already gone → idempotent success
805
+ if (resp.status === 404) {
806
+ return { deleted: false, branch: null, reason: "not_found" };
807
+ }
808
+ if (!resp.ok) {
809
+ if (resp.status === 401 || resp.status === 403) {
810
+ throw new ConductorBridgeApiError("unauthorized", resp.status);
811
+ }
812
+ if (resp.status >= 500) {
813
+ throw new ConductorBridgeApiError("server", resp.status);
814
+ }
815
+ throw new ConductorBridgeApiError("http", resp.status);
816
+ }
817
+ try {
818
+ return (await resp.json());
819
+ }
820
+ catch {
821
+ throw new ConductorBridgeApiError("network");
822
+ }
823
+ }
824
+ finally {
825
+ clearTimeout(timer);
826
+ }
827
+ }
828
+ /**
829
+ * Call `PUT /jira/tickets/{ticketNumber}/jira-status` to transition the ticket's
830
+ * Jira status on truly-done. Uses `target_status: "auto"` for server-side resolution.
831
+ * Treats HTTP 400 (no matching transition / already in target) as a benign skip.
832
+ * Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network failures.
833
+ */
834
+ export async function transitionJiraStatus(access, ticketNumber, targetStatus = "auto", fetchImpl = globalThis.fetch) {
835
+ if (!ticketNumber) {
836
+ throw new ConductorBridgeApiError("invalid-input");
837
+ }
838
+ // repo_name travels in the JSON body only (TransitionJiraStatusRequest) — the
839
+ // backend ignores any query param, so don't duplicate it in the URL.
840
+ const url = buildConductorJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticketNumber)}/jira-status`);
841
+ const body = JSON.stringify({ repo_name: access.repoName, target_status: targetStatus });
842
+ try {
843
+ await fetchConductorJsonPutWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
844
+ return { status: "transitioned" };
845
+ }
846
+ catch (err) {
847
+ // HTTP 400 = no matching transition / already in target → benign skip.
848
+ if (err instanceof ConductorBridgeApiError && err.kind === "http" && err.status === 400) {
849
+ return { status: "skipped" };
850
+ }
851
+ throw err;
852
+ }
853
+ }
@@ -336,13 +336,13 @@ export function parseEmitEventArgs(argv, deps = {}) {
336
336
  return { input, json: bools.has("--json"), help: false };
337
337
  }
338
338
  /** Run the `emit-event` command. Prints the inserted event summary. */
339
- export function runEmitEventCommand(argv, deps = {}) {
339
+ export async function runEmitEventCommand(argv, deps = {}) {
340
340
  const parsed = parseEmitEventArgs(argv, deps);
341
341
  if (parsed.help) {
342
342
  console.log(getConductorUsage());
343
343
  return 0;
344
344
  }
345
- const result = emitConductorEvent(parsed.input);
345
+ const result = await emitConductorEvent(parsed.input);
346
346
  if (parsed.json) {
347
347
  console.log(JSON.stringify(result));
348
348
  }
@@ -442,13 +442,13 @@ export function parseSendMessageArgs(argv, deps = {}) {
442
442
  * Run `send-message`. Prints compact JSON when `--json` is set; otherwise a
443
443
  * sanitized human summary (message id / status / type only — NEVER the payload).
444
444
  */
445
- export function runSendMessageCommand(argv, deps = {}) {
445
+ export async function runSendMessageCommand(argv, deps = {}) {
446
446
  const parsed = parseSendMessageArgs(argv, deps);
447
447
  if (parsed.help) {
448
448
  console.log(getConductorUsage());
449
449
  return 0;
450
450
  }
451
- const result = sendWorkerMessage(parsed.input);
451
+ const result = await sendWorkerMessage(parsed.input);
452
452
  if (parsed.json) {
453
453
  console.log(JSON.stringify(result));
454
454
  }
@@ -495,13 +495,13 @@ export function parseCheckMessagesArgs(argv) {
495
495
  * Run `check-messages`. Prints compact JSON when `--json` is set; otherwise a
496
496
  * sanitized human summary (counts + per-message id/type only — NEVER payloads).
497
497
  */
498
- export function runCheckMessagesCommand(argv) {
498
+ export async function runCheckMessagesCommand(argv) {
499
499
  const parsed = parseCheckMessagesArgs(argv);
500
500
  if (parsed.help) {
501
501
  console.log(getConductorUsage());
502
502
  return 0;
503
503
  }
504
- const result = checkWorkerMessages(parsed.input);
504
+ const result = await checkWorkerMessages(parsed.input);
505
505
  if (parsed.json) {
506
506
  console.log(JSON.stringify(result));
507
507
  return 0;
@@ -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));
@@ -590,7 +590,7 @@ export function parseGitHookArgs(argv) {
590
590
  * warning) so a conductor producer failure never blocks the git commit/ref update
591
591
  * the hook is attached to.
592
592
  */
593
- export function runGitHookCommand(argv) {
593
+ export async function runGitHookCommand(argv) {
594
594
  let parsed;
595
595
  try {
596
596
  parsed = parseGitHookArgs(argv);
@@ -602,7 +602,7 @@ export function runGitHookCommand(argv) {
602
602
  }
603
603
  try {
604
604
  if (parsed.subcommand === "post-commit") {
605
- runPostCommitHookProducer();
605
+ await runPostCommitHookProducer();
606
606
  return 0;
607
607
  }
608
608
  // reference-transaction: read the captured updates from the stdin file.
@@ -625,7 +625,7 @@ export function runGitHookCommand(argv) {
625
625
  }
626
626
  }
627
627
  }
628
- runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
628
+ await runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
629
629
  return 0;
630
630
  }
631
631
  catch {
@@ -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");
@@ -975,13 +996,13 @@ export async function runSuperviseCommand(argv) {
975
996
  return result.exit_code;
976
997
  }
977
998
  /** Run the explicit `purge` command. Prints deleted row counts. */
978
- export function runPurgeCommand(argv) {
999
+ export async function runPurgeCommand(argv) {
979
1000
  const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
980
1001
  if (bools.has("--help")) {
981
1002
  console.log(getConductorUsage());
982
1003
  return 0;
983
1004
  }
984
- const result = purgeConductorLedger();
1005
+ const result = await purgeConductorLedger();
985
1006
  if (bools.has("--json")) {
986
1007
  console.log(JSON.stringify(result));
987
1008
  return 0;
@@ -1014,7 +1035,7 @@ export async function runConductorCli(argv) {
1014
1035
  try {
1015
1036
  switch (parsed.command) {
1016
1037
  case "emit-event":
1017
- return runEmitEventCommand(parsed.argv);
1038
+ return await runEmitEventCommand(parsed.argv);
1018
1039
  case "supervise":
1019
1040
  return await runSuperviseCommand(parsed.argv);
1020
1041
  case "epic-tick":
@@ -1024,17 +1045,17 @@ export async function runConductorCli(argv) {
1024
1045
  case "epic-status":
1025
1046
  return await runEpicStatusCommand(parsed.argv);
1026
1047
  case "send-message":
1027
- return runSendMessageCommand(parsed.argv);
1048
+ return await runSendMessageCommand(parsed.argv);
1028
1049
  case "check-messages":
1029
- return runCheckMessagesCommand(parsed.argv);
1050
+ return await runCheckMessagesCommand(parsed.argv);
1030
1051
  case "doctor":
1031
1052
  return await runDoctorCommand(parsed.argv);
1032
1053
  case "purge":
1033
- return runPurgeCommand(parsed.argv);
1054
+ return await runPurgeCommand(parsed.argv);
1034
1055
  case "install-git-hooks":
1035
1056
  return runInstallGitHooksCommand(parsed.argv);
1036
1057
  case "git-hook":
1037
- return runGitHookCommand(parsed.argv);
1058
+ return await runGitHookCommand(parsed.argv);
1038
1059
  default:
1039
1060
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1040
1061
  return 1;