@bridge_gpt/mcp-server 0.2.2 → 0.2.4

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +554 -66
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +17 -9
@@ -0,0 +1,625 @@
1
+ /**
2
+ * TypeScript-side Bridge API access layer for the conductor producer (BAPI-395).
3
+ *
4
+ * The producer reads per-repo gate config and polls CI through the EXISTING
5
+ * Bridge API HTTP surface — it never opens a Postgres connection from TypeScript.
6
+ * Credential and repo identity resolution reuse the shared
7
+ * {@link resolveBapiCredentials} / {@link resolveStartTicketsRepoName} boundaries
8
+ * so the `bapi:<repo>` target can never drift. All errors are sanitized: a thrown
9
+ * {@link ConductorBridgeApiError} carries only a coarse `kind`, an optional HTTP
10
+ * status, and generic text — never a response body, header, token, or API key.
11
+ */
12
+ import os from "node:os";
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import { resolveBapiCredentials } from "../credential-store.js";
15
+ import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
16
+ import { DONE_GATE_CONFIG_FIELD, normalizePrNumber, normalizeSha } from "./git-ci-types.js";
17
+ import { ConductorValidationError } from "./errors.js";
18
+ /** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
19
+ export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
20
+ /** Default per-request timeout for conductor Bridge API calls. */
21
+ export const CONDUCTOR_FETCH_TIMEOUT_MS = 30_000;
22
+ /**
23
+ * Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
24
+ * resolver, and the shared credential store. Never throws and never embeds the
25
+ * secret value in any failure text. No Postgres/DB module is imported here.
26
+ */
27
+ export async function resolveConductorBridgeApiAccess(deps = {}) {
28
+ const env = deps.env ?? process.env;
29
+ const cwd = deps.cwd ?? process.cwd();
30
+ const homedir = deps.homedir ?? os.homedir;
31
+ const platform = deps.platform ?? process.platform;
32
+ const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
33
+ const statImpl = deps.stat ?? ((p) => stat(p));
34
+ const repoName = await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl });
35
+ if (!repoName) {
36
+ return {
37
+ ok: false,
38
+ kind: "repo-missing",
39
+ error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
40
+ };
41
+ }
42
+ let credResult;
43
+ try {
44
+ credResult = await resolveBapiCredentials(repoName, {
45
+ env,
46
+ homedir,
47
+ platform,
48
+ readFile: readFileImpl,
49
+ stat: statImpl,
50
+ });
51
+ }
52
+ catch {
53
+ return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
54
+ }
55
+ if (!credResult.ok) {
56
+ return { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
57
+ }
58
+ const baseUrlRaw = env.BAPI_BASE_URL;
59
+ const baseUrl = typeof baseUrlRaw === "string" && baseUrlRaw.trim().length > 0
60
+ ? baseUrlRaw.trim()
61
+ : CONDUCTOR_DEFAULT_BASE_URL;
62
+ return { ok: true, access: { repoName, apiKey: credResult.credentials.apiKey, baseUrl } };
63
+ }
64
+ /**
65
+ * Build a `${baseUrl}/jira${apiPath}` URL with query params, trimming trailing
66
+ * slashes on the base so there is never a double slash after the host. Query
67
+ * values are URL-encoded by {@link URL}.
68
+ */
69
+ export function buildConductorJiraUrl(baseUrl, apiPath, params = {}) {
70
+ const trimmed = baseUrl.replace(/\/+$/, "");
71
+ const url = new URL(`${trimmed}/jira${apiPath}`);
72
+ for (const [k, v] of Object.entries(params)) {
73
+ url.searchParams.set(k, v);
74
+ }
75
+ return url.toString();
76
+ }
77
+ /** Sanitized HTTP error for conductor Bridge API calls — never leaks secrets. */
78
+ export class ConductorBridgeApiError extends Error {
79
+ kind;
80
+ status;
81
+ constructor(kind, status) {
82
+ super(`Conductor Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
83
+ this.name = "ConductorBridgeApiError";
84
+ this.kind = kind;
85
+ this.status = status;
86
+ }
87
+ }
88
+ /** GET auth headers. The API key travels ONLY in a header, never in the URL. */
89
+ function conductorGetHeaders(access) {
90
+ return { "X-API-Key": access.apiKey };
91
+ }
92
+ /**
93
+ * GET JSON with an `AbortController` timeout. Throws a sanitized
94
+ * {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
95
+ * non-2xx response — never including the response body, headers, or API key. The
96
+ * timer is always cleared.
97
+ */
98
+ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = fetch) {
99
+ const controller = new AbortController();
100
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
101
+ try {
102
+ let resp;
103
+ try {
104
+ resp = await fetchImpl(url, { headers, signal: controller.signal });
105
+ }
106
+ catch {
107
+ // Aborts (timeout), DNS/connection failures, and other fetch exceptions.
108
+ throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
109
+ }
110
+ if (!resp.ok) {
111
+ if (resp.status === 401 || resp.status === 403) {
112
+ throw new ConductorBridgeApiError("unauthorized", resp.status);
113
+ }
114
+ if (resp.status >= 500) {
115
+ throw new ConductorBridgeApiError("server", resp.status);
116
+ }
117
+ throw new ConductorBridgeApiError("http", resp.status);
118
+ }
119
+ try {
120
+ return await resp.json();
121
+ }
122
+ catch {
123
+ throw new ConductorBridgeApiError("network");
124
+ }
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+ /**
131
+ * GET `/jira/config-field/{fieldName}?repo_name=<repo>` and return the raw
132
+ * `value` payload (or `undefined` when the envelope lacks a `value`). The field
133
+ * name is URL-encoded; the repo scope is always included.
134
+ */
135
+ export async function fetchConductorConfigField(access, fieldName, fetchImpl = fetch) {
136
+ const url = buildConductorJiraUrl(access.baseUrl, `/config-field/${encodeURIComponent(fieldName)}`, {
137
+ repo_name: access.repoName,
138
+ });
139
+ const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
140
+ if (body && typeof body === "object" && "value" in body) {
141
+ return body.value;
142
+ }
143
+ return undefined;
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);
148
+ }
149
+ /**
150
+ * Poll CI checks for a commit through the existing `/poll-ci-checks` endpoint.
151
+ * Validates `commitRef` with {@link normalizeSha} and rejects BEFORE issuing the
152
+ * request when it is not a valid SHA. Returns the endpoint response verbatim —
153
+ * the client never invents `pr_number`/`head_sha` fields.
154
+ */
155
+ export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch) {
156
+ const sha = normalizeSha(commitRef);
157
+ if (sha === null) {
158
+ throw new ConductorBridgeApiError("invalid-input");
159
+ }
160
+ const url = buildConductorJiraUrl(access.baseUrl, "/poll-ci-checks", {
161
+ repo_name: access.repoName,
162
+ commit_ref: sha,
163
+ });
164
+ return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Conductor C6 (BAPI-398): protected VCS merge POST client
168
+ // ---------------------------------------------------------------------------
169
+ /**
170
+ * Build a `${baseUrl}${apiPath}` URL for non-`/jira` API routes (e.g. the
171
+ * protected `/vcs/...` merge endpoint), trimming trailing slashes on the base.
172
+ * Distinct from {@link buildConductorJiraUrl}, which forces a `/jira` prefix.
173
+ */
174
+ export function buildConductorVcsUrl(baseUrl, apiPath) {
175
+ const trimmed = baseUrl.replace(/\/+$/, "");
176
+ const path = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
177
+ return new URL(`${trimmed}${path}`).toString();
178
+ }
179
+ /** POST auth + content headers. The API key travels ONLY in a header. */
180
+ function conductorPostHeaders(access) {
181
+ return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
182
+ }
183
+ /**
184
+ * POST JSON with an `AbortController` timeout, mirroring
185
+ * {@link fetchConductorJsonWithTimeout}. Throws a sanitized
186
+ * {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
187
+ * non-2xx response — never including the response body, headers, API key, or
188
+ * request body in the error. The timer is always cleared.
189
+ */
190
+ export async function fetchConductorJsonPostWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
191
+ const controller = new AbortController();
192
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
193
+ try {
194
+ let resp;
195
+ try {
196
+ resp = await fetchImpl(url, { method: "POST", headers, body, signal: controller.signal });
197
+ }
198
+ catch {
199
+ throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
200
+ }
201
+ if (!resp.ok) {
202
+ if (resp.status === 401 || resp.status === 403) {
203
+ throw new ConductorBridgeApiError("unauthorized", resp.status);
204
+ }
205
+ if (resp.status >= 500) {
206
+ throw new ConductorBridgeApiError("server", resp.status);
207
+ }
208
+ throw new ConductorBridgeApiError("http", resp.status);
209
+ }
210
+ try {
211
+ return await resp.json();
212
+ }
213
+ catch {
214
+ throw new ConductorBridgeApiError("network");
215
+ }
216
+ }
217
+ finally {
218
+ clearTimeout(timer);
219
+ }
220
+ }
221
+ /**
222
+ * Call the protected `POST /vcs/pull-requests/{pr_number}/merge` endpoint. The PR
223
+ * number lives ONLY in the path; the API key travels ONLY in headers; the body
224
+ * carries no branch name or provider token. PR number and head SHA are validated
225
+ * before the request is issued. Throws a sanitized {@link ConductorBridgeApiError}
226
+ * on any failure.
227
+ */
228
+ export async function mergePullRequestForGate(access, request, fetchImpl = fetch) {
229
+ const pr = normalizePrNumber(request.pr_number);
230
+ const sha = normalizeSha(request.expected_head_sha);
231
+ if (pr === null || sha === null || !request.action_key || !request.repo_name) {
232
+ throw new ConductorBridgeApiError("invalid-input");
233
+ }
234
+ const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/merge`);
235
+ // The body deliberately omits pr_number (it is in the path) and never includes
236
+ // a branch name or provider token; extra fields would be rejected server-side.
237
+ const body = JSON.stringify({
238
+ repo_name: request.repo_name,
239
+ expected_head_sha: sha,
240
+ gate: request.gate,
241
+ action_key: request.action_key,
242
+ ...(request.gate_event ? { gate_event: request.gate_event } : {}),
243
+ });
244
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
245
+ return parsed;
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Local boundary validators (reject before any fetch call)
249
+ // ---------------------------------------------------------------------------
250
+ function requireNonEmptyString(value) {
251
+ if (typeof value !== "string" || value.trim().length === 0) {
252
+ throw new ConductorBridgeApiError("invalid-input");
253
+ }
254
+ }
255
+ function requirePositiveSafeInteger(value) {
256
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
257
+ throw new ConductorBridgeApiError("invalid-input");
258
+ }
259
+ }
260
+ function requireNonNegativeSafeInteger(value) {
261
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
262
+ throw new ConductorBridgeApiError("invalid-input");
263
+ }
264
+ }
265
+ function requireNoSlashPathSegment(value) {
266
+ if (value.includes("/")) {
267
+ throw new ConductorBridgeApiError("invalid-input");
268
+ }
269
+ }
270
+ const EPIC_TICKET_STATUS_VALUES = [
271
+ "planned", "ready", "dispatched", "running", "blocked", "abandoned", "done",
272
+ ];
273
+ function requireEpicTicketStatusValue(value) {
274
+ if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
275
+ throw new ConductorBridgeApiError("invalid-input");
276
+ }
277
+ }
278
+ const EPIC_DISPATCH_TRANSITION_STATUSES = ["run_spawned", "terminal"];
279
+ function requireEpicDispatchTransitionStatus(value) {
280
+ if (typeof value !== "string" || !EPIC_DISPATCH_TRANSITION_STATUSES.includes(value)) {
281
+ throw new ConductorBridgeApiError("invalid-input");
282
+ }
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // Epic Run route constants and path helpers
286
+ // ---------------------------------------------------------------------------
287
+ // TODO(epic-run-store): confirm final protected route prefix remains /jira/epic-runs against the sibling durable-store routes.
288
+ const EPIC_RUNS_API_PREFIX = "/epic-runs";
289
+ function epicRunApiPath(epicKey) {
290
+ return `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}`;
291
+ }
292
+ function epicDispatchTransitionApiPath(dispatchKey, nextStatus) {
293
+ if (nextStatus === "run_spawned") {
294
+ return `/epic-runs/dispatch/${encodeURIComponent(dispatchKey)}/run-spawned`;
295
+ }
296
+ return `/epic-runs/dispatch/${encodeURIComponent(dispatchKey)}/terminal`;
297
+ }
298
+ /**
299
+ * Build the canonical dispatch idempotency key in lock-step with the server-side
300
+ * `build_dispatch_key` Python helper.
301
+ *
302
+ * Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`.
303
+ *
304
+ * // 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
+ */
306
+ export function buildEpicDispatchKey(epicKey, ticketKey, planVersion) {
307
+ requireNonEmptyString(epicKey);
308
+ requireNonEmptyString(ticketKey);
309
+ requireNonNegativeSafeInteger(planVersion);
310
+ return `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
311
+ }
312
+ // ---------------------------------------------------------------------------
313
+ // Epic supervision lease claim/renew
314
+ // ---------------------------------------------------------------------------
315
+ function parseEpicSupervisionLeaseResult(parsed) {
316
+ if (!parsed || typeof parsed !== "object") {
317
+ throw new ConductorBridgeApiError("server");
318
+ }
319
+ const p = parsed;
320
+ const row = p["row"];
321
+ if (p["claimed"] === true && row && typeof row === "object") {
322
+ return { ok: true, kind: "acquired-or-renewed", row: row };
323
+ }
324
+ if (p["claimed"] === false && p["reason"] === "lease_held" && row && typeof row === "object") {
325
+ return { ok: false, kind: "held-by-other", reason: "lease_held", row: row };
326
+ }
327
+ if (p["claimed"] === false && p["reason"] === "terminal" && row && typeof row === "object") {
328
+ return { ok: false, kind: "terminal", reason: "terminal", row: row };
329
+ }
330
+ throw new ConductorBridgeApiError("server");
331
+ }
332
+ /**
333
+ * POST to the epic supervision lease endpoint. Returns a typed result distinguishing
334
+ * acquired/renewed from held-by-other and terminal so the control loop can decide
335
+ * whether to act or exit as an observer. Validates inputs before the request.
336
+ */
337
+ export async function claimEpicSupervisionLease(access, request, fetchImpl = fetch) {
338
+ requireNonEmptyString(request.epicKey);
339
+ requireNonEmptyString(request.leaseOwner);
340
+ requirePositiveSafeInteger(request.ttlSeconds);
341
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/lease/claim`);
342
+ const body = JSON.stringify({
343
+ repo_name: access.repoName,
344
+ lease_owner: request.leaseOwner,
345
+ ttl_seconds: request.ttlSeconds,
346
+ });
347
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
348
+ return parseEpicSupervisionLeaseResult(parsed);
349
+ }
350
+ // ---------------------------------------------------------------------------
351
+ // Epic desired + observed state read
352
+ // ---------------------------------------------------------------------------
353
+ /**
354
+ * GET the durable epic run state (epic record + per-ticket statuses + dispatches).
355
+ * Returns the server payload verbatim — no ready-set computation, plan-hash
356
+ * assertion, or derived control-loop fields are added.
357
+ */
358
+ export async function fetchEpicRunState(access, epicKey, fetchImpl = fetch) {
359
+ requireNonEmptyString(epicKey);
360
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/state`, { repo_name: access.repoName });
361
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
362
+ return parsed;
363
+ }
364
+ // ---------------------------------------------------------------------------
365
+ // Per-ticket CAS status advancement
366
+ // ---------------------------------------------------------------------------
367
+ function parseAdvanceEpicTicketStatusResult(parsed) {
368
+ if (!parsed || typeof parsed !== "object") {
369
+ throw new ConductorBridgeApiError("server");
370
+ }
371
+ const p = parsed;
372
+ // Structured CAS conflict envelope
373
+ if (p["ok"] === false && p["kind"] === "cas-conflict") {
374
+ return {
375
+ ok: false,
376
+ kind: "cas-conflict",
377
+ ...(typeof p["current_row_version"] === "number"
378
+ ? { current_row_version: p["current_row_version"] }
379
+ : {}),
380
+ ...(p["ticket_status"] && typeof p["ticket_status"] === "object"
381
+ ? { ticket_status: p["ticket_status"] }
382
+ : {}),
383
+ };
384
+ }
385
+ // Structured success envelope
386
+ if (p["ok"] === true && p["ticket_status"] && typeof p["ticket_status"] === "object") {
387
+ return { ok: true, ticket_status: p["ticket_status"] };
388
+ }
389
+ // Bare sibling success shape (EpicTicketStatusResponse): has ticket_key, status, row_version
390
+ if ("ticket_key" in p && "status" in p && "row_version" in p) {
391
+ return { ok: true, ticket_status: parsed };
392
+ }
393
+ throw new ConductorBridgeApiError("server");
394
+ }
395
+ /**
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.
398
+ * 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
+ */
402
+ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch) {
403
+ requireNonEmptyString(request.epicKey);
404
+ requireNonEmptyString(request.ticketKey);
405
+ requireNonNegativeSafeInteger(request.expectedRowVersion);
406
+ requireNonNegativeSafeInteger(request.planVersion);
407
+ requireEpicTicketStatusValue(request.nextStatus);
408
+ if (request.dispatchRunId !== undefined) {
409
+ requireNonEmptyString(request.dispatchRunId);
410
+ }
411
+ // TODO(epic-run-store): ticket requires POST; sibling is currently PATCH — reconcile at integration
412
+ const CAS_ENDPOINT_PATH = `${epicRunApiPath(request.epicKey)}/tickets/${encodeURIComponent(request.ticketKey)}`;
413
+ const url = buildConductorJiraUrl(access.baseUrl, CAS_ENDPOINT_PATH);
414
+ const body = JSON.stringify({
415
+ repo_name: access.repoName,
416
+ status: request.nextStatus,
417
+ plan_version: request.planVersion,
418
+ expected_row_version: request.expectedRowVersion,
419
+ ...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
420
+ });
421
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
422
+ return parseAdvanceEpicTicketStatusResult(parsed);
423
+ }
424
+ /**
425
+ * Idempotently seed an epic ticket status row via POST to the per-epic tickets
426
+ * endpoint. The backend uses ON CONFLICT DO NOTHING so repeated seeding across
427
+ * ticks and re-plans is safe. Throws a sanitized {@link ConductorBridgeApiError}
428
+ * on any transport/auth/server error.
429
+ */
430
+ export async function createEpicTicketStatus(access, request, fetchImpl = fetch) {
431
+ requireNonEmptyString(request.epicKey);
432
+ requireNonEmptyString(request.ticketKey);
433
+ requireEpicTicketStatusValue(request.status);
434
+ requireNonNegativeSafeInteger(request.planVersion);
435
+ if (request.dispatchRunId !== undefined) {
436
+ requireNonEmptyString(request.dispatchRunId);
437
+ }
438
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/tickets`);
439
+ const body = JSON.stringify({
440
+ repo_name: access.repoName,
441
+ ticket_key: request.ticketKey,
442
+ status: request.status,
443
+ plan_version: request.planVersion,
444
+ ...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
445
+ });
446
+ await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
447
+ // fetchConductorJsonPostWithTimeout already throws on 4xx/5xx;
448
+ // the 201 response body is not needed (we return void).
449
+ }
450
+ // ---------------------------------------------------------------------------
451
+ // Idempotent dispatch recording
452
+ // ---------------------------------------------------------------------------
453
+ function parseEpicDispatchResult(parsed) {
454
+ if (!parsed || typeof parsed !== "object") {
455
+ throw new ConductorBridgeApiError("server");
456
+ }
457
+ const p = parsed;
458
+ const row = p["row"];
459
+ if (!row || typeof row !== "object") {
460
+ throw new ConductorBridgeApiError("server");
461
+ }
462
+ const dispatch = row;
463
+ if (p["claimed"] === true) {
464
+ return { ok: true, kind: "claimed", dispatch };
465
+ }
466
+ if (p["claimed"] === false) {
467
+ const reason = p["reason"];
468
+ if (reason === "already_spawned") {
469
+ return { ok: true, kind: "already-spawned", dispatch };
470
+ }
471
+ if (reason === "already_exists") {
472
+ return { ok: true, kind: "already-exists", dispatch };
473
+ }
474
+ if (reason === "terminal") {
475
+ return { ok: true, kind: "terminal", terminal: true, dispatch };
476
+ }
477
+ if (reason === "lease_held") {
478
+ return { ok: false, kind: "lease-held", dispatch };
479
+ }
480
+ }
481
+ throw new ConductorBridgeApiError("server");
482
+ }
483
+ /**
484
+ * POST to the dispatch claim endpoint. Treats dispatch creation as idempotent:
485
+ * re-recording the same `dispatch_key` returns the existing dispatch as a normal
486
+ * outcome so a crashed-then-retried tick never double-dispatches. The dispatch key
487
+ * is composed exclusively via {@link buildEpicDispatchKey}.
488
+ */
489
+ export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
490
+ requireNonEmptyString(request.epicKey);
491
+ requireNonEmptyString(request.ticketKey);
492
+ requireNonEmptyString(request.leaseOwner);
493
+ requireNonNegativeSafeInteger(request.planVersion);
494
+ requirePositiveSafeInteger(request.ttlSeconds);
495
+ const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion);
496
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/dispatch/claim`);
497
+ const body = JSON.stringify({
498
+ repo_name: access.repoName,
499
+ epic_run_id: request.epicKey,
500
+ ticket_key: request.ticketKey,
501
+ plan_version: request.planVersion,
502
+ lease_owner: request.leaseOwner,
503
+ ttl_seconds: request.ttlSeconds,
504
+ dispatch_key: dispatchKey,
505
+ });
506
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
507
+ return parseEpicDispatchResult(parsed);
508
+ }
509
+ // ---------------------------------------------------------------------------
510
+ // Dispatch state transitions
511
+ // ---------------------------------------------------------------------------
512
+ /**
513
+ * POST to the appropriate dispatch transition endpoint (`run-spawned` or `terminal`).
514
+ * `run_spawned` requires a non-empty `runId`; `terminal` omits it even if supplied.
515
+ * Dispatch keys containing `/` are rejected before the request because transition
516
+ * endpoints embed the key as a URL path segment.
517
+ */
518
+ export async function transitionEpicDispatch(access, request, fetchImpl = fetch) {
519
+ requireNonEmptyString(request.dispatchKey);
520
+ requireNoSlashPathSegment(request.dispatchKey);
521
+ requireEpicDispatchTransitionStatus(request.nextStatus);
522
+ if (request.nextStatus === "run_spawned") {
523
+ requireNonEmptyString(request.runId);
524
+ }
525
+ const path = epicDispatchTransitionApiPath(request.dispatchKey, request.nextStatus);
526
+ const url = buildConductorJiraUrl(access.baseUrl, path);
527
+ const body = request.nextStatus === "run_spawned"
528
+ ? JSON.stringify({ repo_name: access.repoName, run_id: request.runId })
529
+ : JSON.stringify({ repo_name: access.repoName });
530
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
531
+ return parsed;
532
+ }
533
+ /**
534
+ * POST the immutable plan blob to the durable-store endpoint. The blob is
535
+ * written once per `(epic_run_id, plan_version)` and never mutated — a
536
+ * re-plan must use a strictly higher `plan_version`. The API key travels ONLY
537
+ * in the `X-API-Key` header, never in the URL. Throws a sanitized
538
+ * {@link ConductorBridgeApiError} on any transport/auth/server error.
539
+ */
540
+ export async function storeEpicPlan(access, request, fetchImpl = fetch) {
541
+ requireNonEmptyString(request.epicKey);
542
+ requirePositiveSafeInteger(request.planVersion);
543
+ requireNonEmptyString(request.planHash);
544
+ const blobVersion = request.planBlob.plan_version;
545
+ if (blobVersion !== undefined && blobVersion !== request.planVersion) {
546
+ throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
547
+ }
548
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan`);
549
+ const body = JSON.stringify({
550
+ repo_name: access.repoName,
551
+ plan_version: request.planVersion,
552
+ plan_blob: request.planBlob,
553
+ plan_hash: request.planHash,
554
+ });
555
+ return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
556
+ }
557
+ /**
558
+ * Atomically approve a plan version. Bumps `epic_runs.approved_plan_hash` to
559
+ * the named version's `plan_hash` in a single CAS write — never a
560
+ * read-then-write race. HTTP 409 (Conflict) is trapped and returned as a
561
+ * structured `{ ok: false, kind: "conflict" }` value (stale version or
562
+ * monotonic constraint violation). Other errors throw a sanitized
563
+ * {@link ConductorBridgeApiError}.
564
+ */
565
+ export async function approveEpicPlan(access, request, fetchImpl = fetch) {
566
+ requireNonEmptyString(request.epicKey);
567
+ requirePositiveSafeInteger(request.planVersion);
568
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/approve-plan`);
569
+ const body = JSON.stringify({
570
+ repo_name: access.repoName,
571
+ plan_version: request.planVersion,
572
+ });
573
+ try {
574
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
575
+ return parsed;
576
+ }
577
+ catch (error) {
578
+ if (error instanceof ConductorBridgeApiError && error.status === 409) {
579
+ // ConductorBridgeApiError does not expose the response body, so server-provided
580
+ // conflict subtypes are not accessible here. "superseded" is used as a safe default.
581
+ return { ok: false, kind: "conflict", reason: "superseded" };
582
+ }
583
+ throw error;
584
+ }
585
+ }
586
+ /**
587
+ * GET the stored plan blob for a specific version or the currently-approved
588
+ * version. Pass `"approved"` for `planVersion` to retrieve the approved blob.
589
+ * Returns the response as a {@link GetEpicPlanResponse}. The API key travels
590
+ * ONLY in the `X-API-Key` header, never in the URL.
591
+ */
592
+ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetch) {
593
+ requireNonEmptyString(epicKey);
594
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/plan`, { repo_name: access.repoName, plan_version: String(planVersion) });
595
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
596
+ return parsed;
597
+ }
598
+ // ---------------------------------------------------------------------------
599
+ // Parse pipeline helpers (BAPI-415)
600
+ // ---------------------------------------------------------------------------
601
+ /**
602
+ * GET `/jira/parse-status?repo_name=<repo>` and return the live parse lock
603
+ * status. Only two states are possible: `"in_progress"` (the async parse job
604
+ * holds the lock) and `"idle"` (no lock is held). Throws a sanitized
605
+ * {@link ConductorBridgeApiError} on any transport/auth/server failure.
606
+ */
607
+ export async function fetchParseStatus(access, fetchImpl = fetch) {
608
+ const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
609
+ repo_name: access.repoName,
610
+ });
611
+ const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
612
+ return body;
613
+ }
614
+ /**
615
+ * POST `/jira/parse-repository` to trigger an async repository parse. The
616
+ * endpoint is idempotent (coalesces concurrent requests via
617
+ * `mark_parse_repository_pending`) so duplicate triggers from re-ticking are
618
+ * safe. Returns the response envelope verbatim. Throws a sanitized
619
+ * {@link ConductorBridgeApiError} on any transport/auth/server failure.
620
+ */
621
+ export async function triggerRepositoryParse(access, fetchImpl = fetch) {
622
+ const url = buildConductorJiraUrl(access.baseUrl, "/parse-repository");
623
+ const body = JSON.stringify({ repo_name: access.repoName });
624
+ return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
625
+ }