@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Bridge API client for the `implement-epic` CLI (BAPI-802).
3
+ *
4
+ * This module lives OUTSIDE `mcp_server/src/conductor/`, which is frozen v1. It
5
+ * imports from `conductor/bridge-api-client.ts` in the reuse-only direction the
6
+ * freeze allows — shared URL construction, the access type, and the timeout/
7
+ * sanitization fetch helpers — and adds nothing under `conductor/`. That import
8
+ * is recorded in `conductor/README.md`'s survivor table.
9
+ *
10
+ * Two contract properties, fixed by `implement-epic-bridge-client` and relied on by
11
+ * BAPI-803, which extends this same file:
12
+ *
13
+ * - **No function throws.** Every failure — transport, timeout, HTTP status,
14
+ * malformed body, even a bad `baseUrl` — resolves to
15
+ * `{ ok: false, status, error }`. A CLI that has to wrap each call in
16
+ * `try/catch` gets that wrong exactly once, in the recovery path where it
17
+ * matters most.
18
+ * - **The API key never leaves the `X-API-Key` header.** It is not in a URL, a
19
+ * query string, a request body, or any returned error text. Unrecognized
20
+ * throw values collapse to a stable generic message rather than being
21
+ * stringified, because a stringified unknown is exactly how a credential
22
+ * escapes.
23
+ *
24
+ * Credentials are NOT resolved here: the caller passes an access object it
25
+ * already obtained from `resolveConductorBridgeApiAccess()`, which reads
26
+ * `BAPI_API_KEY` then the user-scoped credential store.
27
+ *
28
+ * No MCP tool is registered from this module — the `tools/list` token budget is
29
+ * effectively frozen.
30
+ */
31
+ import { buildConductorJiraUrl, buildConductorVcsUrl, fetchConductorJsonPostWithTimeout, fetchConductorJsonPutWithTimeout, fetchConductorJsonWithTimeout, fetchConductorReadiness, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchEpicRunState, fetchParseStatus, fetchPrReviewStatus, pollCiChecksForCommit, ConductorBridgeApiError, CONDUCTOR_FETCH_TIMEOUT_MS, } from "../conductor/bridge-api-client.js";
32
+ /**
33
+ * The one message returned for any failure whose cause cannot be described
34
+ * without risking the credential. Stable so callers can match on it.
35
+ */
36
+ const GENERIC_ERROR = "Bridge API request failed";
37
+ /** GET headers. The key travels in this header and nowhere else. */
38
+ function getHeaders(access) {
39
+ return { "X-API-Key": access.apiKey };
40
+ }
41
+ /** POST headers. Same rule, plus the JSON content type. */
42
+ function postHeaders(access) {
43
+ return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
44
+ }
45
+ /** PUT headers. Identical to {@link postHeaders}; named for the call site. */
46
+ function putHeaders(access) {
47
+ return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
48
+ }
49
+ /**
50
+ * Remove the exact API key from a message that is otherwise safe to surface.
51
+ *
52
+ * The shared helpers already redact header values from their diagnostics, so
53
+ * this is a second, independent pass over the final string: the belt is cheap
54
+ * and the failure it guards against is unrecoverable. Very short keys are left
55
+ * alone because scrubbing a 1-3 character string would mangle ordinary prose.
56
+ */
57
+ function scrubKey(text, apiKey) {
58
+ if (!apiKey || apiKey.length < 4)
59
+ return text;
60
+ return text.split(apiKey).join("[REDACTED]");
61
+ }
62
+ /**
63
+ * Convert any thrown value into a `{ ok: false }` result.
64
+ *
65
+ * A `ConductorBridgeApiError` carries a message the shared helper already built
66
+ * from the coarse kind, HTTP status, backend error code, and a bounded,
67
+ * secret-redacted body preview — that is what lets a 409 name the override
68
+ * branch the server preserved. Anything else is collapsed to
69
+ * {@link GENERIC_ERROR}: an unknown throw value has unknown contents, and
70
+ * stringifying it is how a credential or a raw response body escapes.
71
+ */
72
+ function toFailure(err, access) {
73
+ if (err instanceof ConductorBridgeApiError) {
74
+ return {
75
+ ok: false,
76
+ status: typeof err.status === "number" ? err.status : null,
77
+ error: scrubKey(err.message || GENERIC_ERROR, access.apiKey),
78
+ };
79
+ }
80
+ return { ok: false, status: null, error: GENERIC_ERROR };
81
+ }
82
+ /** True when `value` is a plain (non-array, non-null) object. */
83
+ function isRecord(value) {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+ /** Read a `string | null` field, returning `undefined` when it is neither. */
87
+ function nullableString(value) {
88
+ if (value === null)
89
+ return null;
90
+ if (typeof value === "string")
91
+ return value;
92
+ return undefined;
93
+ }
94
+ /**
95
+ * Run `operation` and convert any thrown value into a sanitized failure result.
96
+ *
97
+ * Every +803 read is this shape, so the try/catch lives in one place: a helper
98
+ * added later cannot forget it and reintroduce a throwing client.
99
+ */
100
+ async function wrap(access, operation) {
101
+ try {
102
+ return { ok: true, value: await operation() };
103
+ }
104
+ catch (err) {
105
+ return toFailure(err, access);
106
+ }
107
+ }
108
+ /**
109
+ * `PUT /jira/epic-runs/supervisor-config/defaults/?repo_name=` — replace the
110
+ * project-default supervisor configuration.
111
+ *
112
+ * The complete {@link EpicSupervisorConfigRequest} is sent. `init` builds it by
113
+ * reading the effective config and flipping exactly one field, so enabling
114
+ * auto-merge never silently reverts another operator setting.
115
+ */
116
+ export async function putSupervisorConfigDefaults(access, config, fetchImpl = globalThis.fetch) {
117
+ return wrap(access, () => {
118
+ const url = buildConductorJiraUrl(access.baseUrl, "/epic-runs/supervisor-config/defaults/", { repo_name: access.repoName });
119
+ return fetchConductorJsonPutWithTimeout(url, putHeaders(access), JSON.stringify(config), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
120
+ });
121
+ }
122
+ /** `GET /jira/epic-runs/supervisor-config/defaults/` — the effective config. */
123
+ export async function getEffectiveSupervisorConfig(access, fetchImpl = globalThis.fetch) {
124
+ return wrap(access, () => fetchEffectiveSupervisorConfig(access, undefined, fetchImpl));
125
+ }
126
+ /**
127
+ * `GET /jira/epic-runs/supervisor-setup/defaults/` — the effective setup.
128
+ *
129
+ * Returned verbatim, `done_gate_config` included and un-normalized: it is the
130
+ * raw operator-authored JSONB that `parseDoneGateConfig` is the sole authority
131
+ * on, and normalizing it here would fork that vocabulary.
132
+ */
133
+ export async function getEffectiveSupervisorSetup(access, fetchImpl = globalThis.fetch) {
134
+ return wrap(access, () => fetchEffectiveSupervisorSetup(access, undefined, fetchImpl));
135
+ }
136
+ /**
137
+ * `GET /jira/epic-runs/conductor-readiness?repo_name=` — readiness facts.
138
+ *
139
+ * `init` reads three of them: `supervisor.auto_merge_enabled`,
140
+ * `supervisor.required_checks_count`, and `supervisor.required_checks_empty`.
141
+ * The underlying fetcher validates the body fail-closed, so a 200 whose body is
142
+ * missing `required_checks_empty` becomes a failure here rather than an
143
+ * apparently-healthy report.
144
+ */
145
+ export async function getConductorReadiness(access, fetchImpl = globalThis.fetch) {
146
+ return wrap(access, () => fetchConductorReadiness(access, fetchImpl));
147
+ }
148
+ /**
149
+ * `POST /jira/resolve-ci-checks` — discover and classify the repository's CI
150
+ * checks, and warm the `poll-ci-checks` cache as a side effect.
151
+ *
152
+ * `commitRef` is optional and forwarded only when supplied. The response is the
153
+ * server's soft envelope, preserved verbatim inside `value`.
154
+ */
155
+ export async function resolveCiChecks(access, commitRef, fetchImpl = globalThis.fetch) {
156
+ return wrap(access, () => {
157
+ const url = buildConductorJiraUrl(access.baseUrl, "/resolve-ci-checks");
158
+ const payload = { repo_name: access.repoName };
159
+ if (typeof commitRef === "string" && commitRef.trim().length > 0) {
160
+ payload.commit_ref = commitRef;
161
+ }
162
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify(payload), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
163
+ });
164
+ }
165
+ /**
166
+ * `GET /jira/poll-ci-checks?repo_name=&commit_ref=` — CI status for one SHA.
167
+ *
168
+ * Until `resolve-ci-checks` has run once for the repository this answers 200
169
+ * with `{ available: false, action: "Call resolve-ci-checks first…" }`. That is
170
+ * a successful result carrying an instruction, not an error, and it stays in
171
+ * `value` so `status` can act on it.
172
+ */
173
+ export async function pollCiChecks(access, commitRef, fetchImpl = globalThis.fetch) {
174
+ return wrap(access, () => pollCiChecksForCommit(access, commitRef, fetchImpl));
175
+ }
176
+ /**
177
+ * `GET /vcs/pull-requests/{n}/reviews/status?repo_name=` — normalized review
178
+ * state. Root-mounted, NOT under `/jira`.
179
+ *
180
+ * `prNumber` is validated by the wrapped fetcher before any request is issued,
181
+ * so a zero/negative/non-integer number fails without a network round trip.
182
+ */
183
+ export async function getPrReviewStatus(access, prNumber, fetchImpl = globalThis.fetch) {
184
+ return wrap(access, () => fetchPrReviewStatus(access, prNumber, fetchImpl));
185
+ }
186
+ /** `GET /jira/parse-status?repo_name=` — the durable parse-run status. */
187
+ export async function getParseStatus(access, fetchImpl = globalThis.fetch) {
188
+ return wrap(access, () => fetchParseStatus(access, fetchImpl));
189
+ }
190
+ /**
191
+ * `GET /jira/epic-runs/runs/{epicKey}/state?repo_name=` — the durable epic-run
192
+ * state.
193
+ *
194
+ * A pure READ. `implement-epic` never creates an `epic_run`; `init` calls this
195
+ * only to refuse when the server is already conducting the same epic through
196
+ * the v2 reconciler, which would otherwise drive the same tickets in parallel.
197
+ */
198
+ export async function getEpicRunState(access, epicKey, fetchImpl = globalThis.fetch) {
199
+ return wrap(access, () => fetchEpicRunState(access, epicKey, fetchImpl));
200
+ }
201
+ /**
202
+ * `GET /jira/config-field/base_branch?repo_name=` — the repository's configured
203
+ * base branch.
204
+ *
205
+ * Normalized defensively: an absent `value`, an explicit `null`, and a
206
+ * blank string all become `base_branch: null`, which the caller reads as "not
207
+ * configured" and falls through to `main`. A body that is not an object at all
208
+ * is a failure — silently treating an unparseable response as "not configured"
209
+ * would provision the epic branch from the wrong base.
210
+ */
211
+ export async function getConfigFieldBaseBranch(access, fetchImpl = globalThis.fetch) {
212
+ const result = await wrap(access, () => {
213
+ const url = buildConductorJiraUrl(access.baseUrl, "/config-field/base_branch", {
214
+ repo_name: access.repoName,
215
+ });
216
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
217
+ });
218
+ if (!result.ok)
219
+ return result;
220
+ if (!isRecord(result.value)) {
221
+ return { ok: false, status: null, error: GENERIC_ERROR };
222
+ }
223
+ const raw = result.value["value"];
224
+ if (raw === null || raw === undefined) {
225
+ return { ok: true, value: { base_branch: null } };
226
+ }
227
+ if (typeof raw !== "string") {
228
+ return { ok: false, status: null, error: GENERIC_ERROR };
229
+ }
230
+ const trimmed = raw.trim();
231
+ return { ok: true, value: { base_branch: trimmed.length === 0 ? null : trimmed } };
232
+ }
233
+ const INDEX_SCOPE_FRESHNESS_VALUES = new Set([
234
+ "fresh",
235
+ "pending",
236
+ "blocked",
237
+ "failed",
238
+ "unavailable",
239
+ "unobserved_advance",
240
+ ]);
241
+ /**
242
+ * Read the server's freshness verdict, failing CLOSED.
243
+ *
244
+ * An absent or unrecognized value becomes `null` rather than a guess, and every
245
+ * caller treats `null` as "not fresh". A malformed control-plane response must
246
+ * never be the thing that lets a conductor proceed against a stale index.
247
+ */
248
+ function parseFreshnessStatus(value) {
249
+ return typeof value === "string" && INDEX_SCOPE_FRESHNESS_VALUES.has(value)
250
+ ? value
251
+ : null;
252
+ }
253
+ /**
254
+ * Read a soft envelope's refusal, or `null` when the body reports success.
255
+ *
256
+ * A body that is not an object at all is a refusal too: silently treating an
257
+ * unparseable response as success is how a cut gets recorded against a commit
258
+ * nobody verified.
259
+ */
260
+ function softEnvelopeFailure(body) {
261
+ if (!isRecord(body))
262
+ return { ok: false, status: null, error: GENERIC_ERROR };
263
+ if (body["ok"] === true)
264
+ return null;
265
+ const error = typeof body["error"] === "string" && body["error"].length > 0 ? body["error"] : GENERIC_ERROR;
266
+ const message = typeof body["message"] === "string" && body["message"].length > 0 ? body["message"] : null;
267
+ return { ok: false, status: null, error: message ? `${error}: ${message}` : error };
268
+ }
269
+ /** Read a required string field, or `null` when it is absent or empty. */
270
+ function requiredString(body, key) {
271
+ const value = body[key];
272
+ return typeof value === "string" && value.length > 0 ? value : null;
273
+ }
274
+ /**
275
+ * `POST /jira/index-scope/cut/begin` — lease a cut hold at the indexed commit.
276
+ *
277
+ * `candidateCommitSha` is the CLI's own preflight observation, sent as a
278
+ * cross-check. The server re-reads the canonical snapshot under the parse lock
279
+ * and refuses on disagreement, which is the "the index moved while I was
280
+ * checking" case the protocol re-drives rather than papers over.
281
+ */
282
+ export async function beginIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
283
+ const result = await wrap(access, () => {
284
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/begin");
285
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
286
+ repo_name: access.repoName,
287
+ feature_branch: request.featureBranch,
288
+ base_branch: request.baseBranch,
289
+ candidate_commit_sha: request.candidateCommitSha ?? null,
290
+ epic_run_id: request.epicRunId ?? null,
291
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
292
+ });
293
+ if (!result.ok)
294
+ return result;
295
+ const refusal = softEnvelopeFailure(result.value);
296
+ if (refusal)
297
+ return refusal;
298
+ const body = result.value;
299
+ const cutHoldId = requiredString(body, "cut_hold_id");
300
+ const scopeId = requiredString(body, "scope_id");
301
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
302
+ const cutCommitSha = requiredString(body, "cut_commit_sha");
303
+ if (!cutHoldId || !scopeId || !shadowRepoName || !cutCommitSha) {
304
+ // An `ok: true` body missing any of these is not a usable lease, and acting
305
+ // on a partial one would push a ref at `undefined`.
306
+ return { ok: false, status: null, error: GENERIC_ERROR };
307
+ }
308
+ return { ok: true, value: { cut_hold_id: cutHoldId, scope_id: scopeId, shadow_repo_name: shadowRepoName, cut_commit_sha: cutCommitSha } };
309
+ }
310
+ /**
311
+ * `POST /jira/index-scope/cut/commit` — record the immutable cut.
312
+ *
313
+ * `epicRefCommitSha` is what the CLI read back from `origin` AFTER creating the
314
+ * ref, not what it intended to create. The server proves the two agree.
315
+ */
316
+ export async function commitIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
317
+ const result = await wrap(access, () => {
318
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/commit");
319
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
320
+ repo_name: access.repoName,
321
+ scope_id: request.scopeId,
322
+ cut_hold_id: request.cutHoldId,
323
+ epic_ref_commit_sha: request.epicRefCommitSha,
324
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
325
+ });
326
+ if (!result.ok)
327
+ return result;
328
+ const refusal = softEnvelopeFailure(result.value);
329
+ if (refusal)
330
+ return refusal;
331
+ const body = result.value;
332
+ const scopeId = requiredString(body, "scope_id");
333
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
334
+ const cutCommitSha = requiredString(body, "cut_commit_sha");
335
+ if (!scopeId || !shadowRepoName || !cutCommitSha) {
336
+ return { ok: false, status: null, error: GENERIC_ERROR };
337
+ }
338
+ return {
339
+ ok: true,
340
+ value: {
341
+ scope_id: scopeId,
342
+ shadow_repo_name: shadowRepoName,
343
+ cut_commit_sha: cutCommitSha,
344
+ outcome: requiredString(body, "outcome"),
345
+ },
346
+ };
347
+ }
348
+ /**
349
+ * `POST /jira/index-scope/cut/abandon` — release this CLI's own cut hold.
350
+ *
351
+ * Called from `init`'s `finally` on every pre-seed outcome. Idempotent: a hold
352
+ * already released (or already reclaimed) still resolves successfully, so the
353
+ * cleanup path can never turn a primary failure into a second one.
354
+ */
355
+ export async function abandonIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
356
+ const result = await wrap(access, () => {
357
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/abandon");
358
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
359
+ repo_name: access.repoName,
360
+ scope_id: request.scopeId,
361
+ cut_hold_id: request.cutHoldId,
362
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
363
+ });
364
+ if (!result.ok)
365
+ return result;
366
+ const refusal = softEnvelopeFailure(result.value);
367
+ if (refusal)
368
+ return refusal;
369
+ return { ok: true, value: true };
370
+ }
371
+ /**
372
+ * `POST /jira/index-scope/bootstrap` — seed the scope and drive its verifying
373
+ * parse.
374
+ *
375
+ * Accepted means SCHEDULED, never ready. Readiness is observed through
376
+ * {@link getIndexScopeStatus}, because the bootstrap copies a repository's whole
377
+ * parse cache and then runs a parse.
378
+ */
379
+ export async function bootstrapIndexScope(access, request, fetchImpl = globalThis.fetch) {
380
+ const result = await wrap(access, () => {
381
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/bootstrap");
382
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
383
+ });
384
+ if (!result.ok)
385
+ return result;
386
+ const refusal = softEnvelopeFailure(result.value);
387
+ if (refusal)
388
+ return refusal;
389
+ return { ok: true, value: true };
390
+ }
391
+ /** `GET /jira/index-scope/status?repo_name=&scope_id=` — the scope's own state. */
392
+ export async function getIndexScopeStatus(access, scopeId, fetchImpl = globalThis.fetch) {
393
+ const result = await wrap(access, () => {
394
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/status", {
395
+ repo_name: access.repoName,
396
+ scope_id: scopeId,
397
+ });
398
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
399
+ });
400
+ if (!result.ok)
401
+ return result;
402
+ const refusal = softEnvelopeFailure(result.value);
403
+ if (refusal)
404
+ return refusal;
405
+ const body = result.value;
406
+ const resolvedScopeId = requiredString(body, "scope_id");
407
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
408
+ const lifecycleState = requiredString(body, "lifecycle_state");
409
+ if (!resolvedScopeId || !shadowRepoName || !lifecycleState) {
410
+ return { ok: false, status: null, error: GENERIC_ERROR };
411
+ }
412
+ return {
413
+ ok: true,
414
+ value: {
415
+ scope_id: resolvedScopeId,
416
+ shadow_repo_name: shadowRepoName,
417
+ lifecycle_state: lifecycleState,
418
+ cut_commit_sha: nullableString(body["cut_commit_sha"]) ?? null,
419
+ required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
420
+ indexed_commit_sha: nullableString(body["indexed_commit_sha"]) ?? null,
421
+ seed_source_commit_sha: nullableString(body["seed_source_commit_sha"]) ?? null,
422
+ last_error: nullableString(body["last_error"]) ?? null,
423
+ freshness_status: parseFreshnessStatus(body["freshness_status"]),
424
+ blocked_reason: nullableString(body["blocked_reason"]) ?? null,
425
+ },
426
+ };
427
+ }
428
+ /**
429
+ * The bounded outcomes `POST /jira/index-scope/catch-up` may report (BAPI-932).
430
+ *
431
+ * Three are successes — the scope was behind and every replayed commit passed
432
+ * the gate (`repaired`); it was already current and nothing was written
433
+ * (`already_current`); it was pinned correctly but not yet indexed, so the
434
+ * existing scheduler was asked to re-drive the parse (`parse_scheduled`). The
435
+ * rest are refusals, and a refusal leaves both watermarks byte-identical.
436
+ */
437
+ export const INDEX_SCOPE_CATCH_UP_OUTCOMES = [
438
+ "repaired",
439
+ "already_current",
440
+ "parse_scheduled",
441
+ "race_lost",
442
+ "blocked",
443
+ "history_limit_exceeded",
444
+ "refused_lifecycle",
445
+ "invalid_scope",
446
+ "unavailable",
447
+ // Route-minted, before the service is reached.
448
+ "unknown_index_scope",
449
+ "epic_shadow_index_lookup_failed",
450
+ "scope_does_not_belong_to_repository",
451
+ ];
452
+ /**
453
+ * `POST /jira/index-scope/catch-up` — replay a scope's missed observations.
454
+ *
455
+ * Fails CLOSED in the same two ways the rest of this client does: a soft-envelope
456
+ * refusal becomes a `BridgeResult` failure, and an unrecognized `outcome` narrows
457
+ * to `null` rather than being passed through. A `null` outcome with `ok: true`
458
+ * cannot happen — an unrecognized success is downgraded to a failure, because
459
+ * "the server said something we do not understand" must never be read by a
460
+ * conductor as "the repair worked".
461
+ */
462
+ export async function catchUpIndexScope(access, request, fetchImpl = globalThis.fetch) {
463
+ const result = await wrap(access, () => {
464
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/catch-up");
465
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
466
+ });
467
+ if (!result.ok)
468
+ return result;
469
+ if (!isRecord(result.value))
470
+ return { ok: false, status: null, error: GENERIC_ERROR };
471
+ const body = result.value;
472
+ const rawOutcome = body["outcome"];
473
+ const outcome = typeof rawOutcome === "string" && INDEX_SCOPE_CATCH_UP_OUTCOMES.includes(rawOutcome)
474
+ ? rawOutcome
475
+ : null;
476
+ const ok = body["ok"] === true;
477
+ if (ok && outcome === null) {
478
+ // A success we cannot name is not a success we may act on.
479
+ return { ok: false, status: null, error: GENERIC_ERROR };
480
+ }
481
+ if (!ok && outcome === null) {
482
+ // A refusal we cannot name still refuses; surface the server's own error
483
+ // token when it gave one, exactly as the soft-envelope helper would.
484
+ const refusal = softEnvelopeFailure(body);
485
+ if (refusal)
486
+ return refusal;
487
+ return { ok: false, status: null, error: GENERIC_ERROR };
488
+ }
489
+ return {
490
+ ok: true,
491
+ value: {
492
+ ok,
493
+ outcome,
494
+ reason: nullableString(body["reason"]) ?? null,
495
+ required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
496
+ parse_scheduled: body["parse_scheduled"] === true,
497
+ },
498
+ };
499
+ }
500
+ function nullableNumber(value) {
501
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
502
+ }
503
+ /**
504
+ * Read a boolean field, defaulting to the SAFE value rather than to `false`.
505
+ *
506
+ * The safe default differs per field and each caller passes it, because guessing
507
+ * uniformly would be wrong in both directions: an unreadable `lease_valid` must
508
+ * read as `true` (assume it is live, do not offer to reclaim it) while an
509
+ * unreadable `retention_elapsed` must read as `false` (assume the window has not
510
+ * passed).
511
+ */
512
+ function booleanOr(value, fallback) {
513
+ return typeof value === "boolean" ? value : fallback;
514
+ }
515
+ function parseLifecycleEntry(value) {
516
+ if (!isRecord(value))
517
+ return null;
518
+ const scopeId = requiredString(value, "scope_id");
519
+ if (!scopeId)
520
+ return null;
521
+ const blockers = Array.isArray(value["blockers"])
522
+ ? value["blockers"].filter((entry) => typeof entry === "string")
523
+ : [];
524
+ return {
525
+ scope_id: scopeId,
526
+ shadow_repo_name: nullableString(value["shadow_repo_name"]) ?? null,
527
+ feature_branch: nullableString(value["feature_branch"]) ?? null,
528
+ epic_run_id: nullableString(value["epic_run_id"]) ?? null,
529
+ lifecycle_state: nullableString(value["lifecycle_state"]) ?? null,
530
+ lease_epoch: nullableNumber(value["lease_epoch"]),
531
+ lease_expires_at: nullableString(value["lease_expires_at"]) ?? null,
532
+ // Fail closed on each: an unreadable field must never make a scope look more
533
+ // reclaimable than it is.
534
+ lease_valid: booleanOr(value["lease_valid"], true),
535
+ retention_deadline: nullableString(value["retention_deadline"]) ?? null,
536
+ retention_elapsed: booleanOr(value["retention_elapsed"], false),
537
+ recoverable: booleanOr(value["recoverable"], false),
538
+ active_parse_run: booleanOr(value["active_parse_run"], true),
539
+ parse_lock_held: booleanOr(value["parse_lock_held"], true),
540
+ active_automation_run: booleanOr(value["active_automation_run"], true),
541
+ active_epic_run: booleanOr(value["active_epic_run"], true),
542
+ identity_valid: booleanOr(value["identity_valid"], false),
543
+ blockers,
544
+ };
545
+ }
546
+ /** `GET /jira/index-scope/lifecycle?repo_name=` — every scope this repo owns. */
547
+ export async function getIndexScopeLifecycle(access, fetchImpl = globalThis.fetch) {
548
+ const result = await wrap(access, () => {
549
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/lifecycle", {
550
+ repo_name: access.repoName,
551
+ });
552
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
553
+ });
554
+ if (!result.ok)
555
+ return result;
556
+ const refusal = softEnvelopeFailure(result.value);
557
+ if (refusal)
558
+ return refusal;
559
+ const body = result.value;
560
+ const rawScopes = Array.isArray(body["scopes"]) ? body["scopes"] : [];
561
+ const scopes = [];
562
+ for (const raw of rawScopes) {
563
+ const parsed = parseLifecycleEntry(raw);
564
+ // A malformed entry is DROPPED rather than failing the whole listing: the
565
+ // listing is a diagnostic, and showing an operator four readable scopes plus
566
+ // nothing about a fifth beats showing them nothing at all.
567
+ if (parsed !== null)
568
+ scopes.push(parsed);
569
+ }
570
+ return {
571
+ ok: true,
572
+ value: { retention_seconds: nullableNumber(body["retention_seconds"]), scopes },
573
+ };
574
+ }
575
+ /** Shared parser for the three lease verbs, whose response shape is identical. */
576
+ function toLeaseResult(body) {
577
+ const refusal = softEnvelopeFailure(body);
578
+ if (refusal)
579
+ return refusal;
580
+ const record = body;
581
+ const scopeId = requiredString(record, "scope_id");
582
+ if (!scopeId)
583
+ return { ok: false, status: null, error: GENERIC_ERROR };
584
+ return {
585
+ ok: true,
586
+ value: {
587
+ scope_id: scopeId,
588
+ lifecycle_state: nullableString(record["lifecycle_state"]) ?? null,
589
+ lease_epoch: nullableNumber(record["lease_epoch"]),
590
+ lease_expires_at: nullableString(record["lease_expires_at"]) ?? null,
591
+ already_retired: booleanOr(record["already_retired"], false),
592
+ },
593
+ };
594
+ }
595
+ async function postScopeLifecycle(access, path, body, fetchImpl) {
596
+ const result = await wrap(access, () => {
597
+ const url = buildConductorJiraUrl(access.baseUrl, path);
598
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, ...body }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
599
+ });
600
+ if (!result.ok)
601
+ return result;
602
+ return toLeaseResult(result.value);
603
+ }
604
+ /** `POST /jira/index-scope/heartbeat` — renew the lease under `leaseEpoch`. */
605
+ export async function heartbeatIndexScope(access, request, fetchImpl = globalThis.fetch) {
606
+ return postScopeLifecycle(access, "/index-scope/heartbeat", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
607
+ }
608
+ /**
609
+ * `POST /jira/index-scope/recover` — take a new ownership generation.
610
+ *
611
+ * Deliberately sends NO epoch: recovery exists for the case where the caller has
612
+ * no valid one, and the server's increment is what fences the previous owner.
613
+ */
614
+ export async function recoverIndexScope(access, request, fetchImpl = globalThis.fetch) {
615
+ return postScopeLifecycle(access, "/index-scope/recover", { scope_id: request.scopeId }, fetchImpl);
616
+ }
617
+ /** `POST /jira/index-scope/retire` — start retention; delete nothing. */
618
+ export async function retireIndexScope(access, request, fetchImpl = globalThis.fetch) {
619
+ return postScopeLifecycle(access, "/index-scope/retire", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
620
+ }
621
+ /**
622
+ * `POST /jira/index-scope/reclaim` — schedule the server-side teardown.
623
+ *
624
+ * There is deliberately no raw-deletion mode and no field that could name a
625
+ * namespace, table, or repository: the only knob is `overrideRetention`, which
626
+ * waives the two TIME blockers and nothing else. Everything actually deleted is
627
+ * derived server-side from the scope id.
628
+ *
629
+ * A refusal carries the server's ordered blocker tokens so the CLI can print WHY
630
+ * rather than a generic failure.
631
+ */
632
+ export async function reclaimIndexScope(access, request, fetchImpl = globalThis.fetch) {
633
+ const result = await wrap(access, () => {
634
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/reclaim");
635
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
636
+ repo_name: access.repoName,
637
+ scope_id: request.scopeId,
638
+ override_retention: request.overrideRetention === true,
639
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
640
+ });
641
+ if (!result.ok)
642
+ return result;
643
+ const refusal = softEnvelopeFailure(result.value);
644
+ if (refusal) {
645
+ const body = isRecord(result.value) ? result.value : {};
646
+ const blockers = Array.isArray(body["blockers"])
647
+ ? body["blockers"].filter((entry) => typeof entry === "string")
648
+ : undefined;
649
+ return blockers && blockers.length > 0 ? { ...refusal, blockers } : refusal;
650
+ }
651
+ const body = result.value;
652
+ return {
653
+ ok: true,
654
+ value: {
655
+ scope_id: nullableString(body["scope_id"]) ?? null,
656
+ scheduled: booleanOr(body["scheduled"], false),
657
+ },
658
+ };
659
+ }
660
+ /**
661
+ * Epic-run statuses that mean a v2 conductor may still be driving the run.
662
+ *
663
+ * These are the NON-TERMINAL members of the server's `EpicRunStatus` vocabulary
664
+ * (`api/models/epic_run.py`: `planning | pending_approval | active | blocked |
665
+ * abandoned | done`); only `abandoned` and `done` are terminal. `active` is the
666
+ * state a run spends nearly all of its life in, so omitting it would make this
667
+ * whole advisory silent for exactly the overlap it exists to surface.
668
+ *
669
+ * The vocabulary matters and is easy to get wrong: `running`/`queued` belong to
670
+ * `ExecutorJobStatusValue` and `paused` to `RunControlState`, and none of the
671
+ * three is ever an `epic_runs.status`. Status alone is not liveness either — the
672
+ * caller additionally requires an unexpired lease, so a `blocked` run whose lease
673
+ * has lapsed is correctly read as wreckage rather than as a live conductor.
674
+ */
675
+ const ACTIVE_EPIC_RUN_STATUSES = [
676
+ "planning",
677
+ "pending_approval",
678
+ "active",
679
+ "blocked",
680
+ ];
681
+ /**
682
+ * `GET /automation/health?repo_name=` — read-only parse-dispatcher liveness.
683
+ *
684
+ * Root-mounted, NOT under `/jira`. The response is narrowed to two safe fields;
685
+ * signals, stale-ticket samples, queue contents, and every timestamp are
686
+ * deliberately dropped — an advisory line needs a verdict, not a report.
687
+ *
688
+ * `absent` is claimed ONLY on positive evidence: a `stale` or `never_seen`
689
+ * heartbeat together with a responding process that is not itself sweeping.
690
+ * Every other shape — `unknown`, a missing field, a malformed body, any read
691
+ * failure — is `unavailable`.
692
+ */
693
+ export async function getParseDispatcherHealth(access, fetchImpl = globalThis.fetch) {
694
+ const result = await wrap(access, () => {
695
+ const url = new URL(buildConductorVcsUrl(access.baseUrl, "/automation/health"));
696
+ url.searchParams.set("repo_name", access.repoName);
697
+ return fetchConductorJsonWithTimeout(url.toString(), getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
698
+ });
699
+ if (!result.ok)
700
+ return result;
701
+ if (!isRecord(result.value)) {
702
+ return {
703
+ ok: true,
704
+ value: { observation: "unavailable", heartbeatState: null, respondingSchedulerRunning: false },
705
+ };
706
+ }
707
+ const reconciler = isRecord(result.value["reconciler"]) ? result.value["reconciler"] : null;
708
+ const scheduler = isRecord(result.value["scheduler"]) ? result.value["scheduler"] : null;
709
+ const rawState = reconciler === null ? undefined : reconciler["state"];
710
+ const heartbeatState = typeof rawState === "string" ? rawState : null;
711
+ const respondingSchedulerRunning = scheduler !== null &&
712
+ scheduler["running"] === true &&
713
+ typeof scheduler["job_count"] === "number" &&
714
+ scheduler["job_count"] > 0;
715
+ let observation;
716
+ if (heartbeatState === "fresh" || respondingSchedulerRunning) {
717
+ observation = "observed";
718
+ }
719
+ else if (heartbeatState === "stale" || heartbeatState === "never_seen") {
720
+ observation = "absent";
721
+ }
722
+ else {
723
+ observation = "unavailable";
724
+ }
725
+ return { ok: true, value: { observation, heartbeatState, respondingSchedulerRunning } };
726
+ }
727
+ /**
728
+ * `GET /jira/epic-runs/runs?repo_name=` — live v2 conductor runs on THIS repo.
729
+ *
730
+ * Scoped by the authenticated repository identity carried on `access`; no caller
731
+ * ever names a repository here. Liveness is judged on the LEASE, not on the
732
+ * status alone: an `active` row whose lease expired is a crashed run, and calling
733
+ * that a concurrent conductor would make the advisory cry wolf on exactly the
734
+ * wreckage `recover` exists to clean up.
735
+ *
736
+ * Only `epic_key` and a controlled state survive into the result. Lease owners,
737
+ * run ids, policies, and timestamps are dropped.
738
+ */
739
+ export async function getLiveRepositoryConductors(access, now, fetchImpl = globalThis.fetch) {
740
+ const result = await wrap(access, () => {
741
+ const url = buildConductorJiraUrl(access.baseUrl, "/epic-runs/runs", {
742
+ repo_name: access.repoName,
743
+ });
744
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
745
+ });
746
+ if (!result.ok)
747
+ return result;
748
+ if (!isRecord(result.value) || !Array.isArray(result.value["runs"])) {
749
+ return { ok: true, value: { observation: "unavailable", conductors: [] } };
750
+ }
751
+ const nowMs = now.getTime();
752
+ const conductors = [];
753
+ for (const entry of result.value["runs"]) {
754
+ if (!isRecord(entry))
755
+ continue;
756
+ const status = entry["status"];
757
+ if (typeof status !== "string" || !ACTIVE_EPIC_RUN_STATUSES.includes(status))
758
+ continue;
759
+ const leaseRaw = nullableString(entry["lease_expires_at"]);
760
+ if (typeof leaseRaw !== "string")
761
+ continue;
762
+ const leaseMs = Date.parse(leaseRaw);
763
+ if (!Number.isFinite(leaseMs) || leaseMs <= nowMs)
764
+ continue;
765
+ conductors.push({
766
+ kind: "v2",
767
+ epicKey: nullableString(entry["epic_key"]) ?? null,
768
+ state: status,
769
+ livenessSource: "lease",
770
+ });
771
+ }
772
+ return { ok: true, value: { observation: conductors.length > 0 ? "observed" : "absent", conductors } };
773
+ }