@bridge_gpt/mcp-server 0.2.62 → 0.2.65

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 (80) hide show
  1. package/README.md +47 -8
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. package/package.json +2 -1
@@ -16,6 +16,7 @@ import { readEpicLaunchProfile, resolveEpicLaunchTarget, } from "../epic-launch-
16
16
  import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
17
17
  import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
18
18
  import { ConductorValidationError } from "./errors.js";
19
+ import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
19
20
  import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
20
21
  /** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
21
22
  export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
@@ -70,12 +71,15 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
70
71
  *
71
72
  * 1. the selected profile is read and validated (host-local, read-only);
72
73
  * 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
73
- * — conflicts refuse, and an absent target is refused, never defaulted to
74
- * {@link CONDUCTOR_DEFAULT_BASE_URL};
74
+ * — conflicts refuse. With none of them set, the target is
75
+ * {@link CONDUCTOR_DEFAULT_BASE_URL} (BAPI-1272);
75
76
  * 3. the repository is decided (profile binding, then `--repo`, then the
76
77
  * shared resolver);
77
78
  * 4. only then is a credential looked up, coupled to that target AND that
78
- * repository ({@link resolveEpicLaunchCredential}).
79
+ * repository ({@link resolveEpicLaunchCredential}). A stored key with no
80
+ * binding is accepted only when the target is production; a key bound to
81
+ * another endpoint, or an ambient key without a matching `BAPI_BASE_URL`,
82
+ * is still refused.
79
83
  *
80
84
  * Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
81
85
  * is deliberately left as it is for every other client.
@@ -104,6 +108,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
104
108
  flagRepo: deps.repoName,
105
109
  profile,
106
110
  env,
111
+ defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
107
112
  });
108
113
  if (!target.ok)
109
114
  return { ok: false, kind: target.kind, error: target.message };
@@ -119,13 +124,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
119
124
  }
120
125
  let cred;
121
126
  try {
122
- cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, {
123
- env,
124
- homedir,
125
- platform,
126
- readFile: readFileImpl,
127
- stat: statImpl,
128
- });
127
+ cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
129
128
  }
130
129
  catch {
131
130
  return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
@@ -750,6 +749,18 @@ export async function remediateEpicTicket(access, request, fetchImpl = globalThi
750
749
  throw err;
751
750
  }
752
751
  }
752
+ /**
753
+ * BAPI-1310 (E1) — the server's refusal codes for the unexplained review-head
754
+ * stop. Each is an HTTP 409 that leaves the ticket parked.
755
+ */
756
+ export const REVIEW_HEAD_REFUSAL_CODES = [
757
+ "REVIEW_HEAD_ADOPTION_REQUIRED",
758
+ "REVIEW_HEAD_EPISODE_CONFLICT",
759
+ "REVIEW_HEAD_STATE_INVALID",
760
+ "REVIEW_HEAD_BINDING_CHANGED",
761
+ "REVIEW_HEAD_LIVE_WORK",
762
+ "REVIEW_HEAD_UNAVAILABLE",
763
+ ];
753
764
  // ---------------------------------------------------------------------------
754
765
  // Local boundary validators (reject before any fetch call)
755
766
  // ---------------------------------------------------------------------------
@@ -1251,6 +1262,108 @@ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.f
1251
1262
  });
1252
1263
  return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
1253
1264
  }
1265
+ const TYPED_CONTINUATION_STATUSES = new Set([200, 201, 409, 503]);
1266
+ const SAFE_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
1267
+ function continuationChildPath(epicRunId, ticketKey) {
1268
+ return `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}`;
1269
+ }
1270
+ function safeDetailErrorCode(body) {
1271
+ if (body === null || typeof body !== "object")
1272
+ return null;
1273
+ const detail = body.detail;
1274
+ if (detail === null || typeof detail !== "object")
1275
+ return null;
1276
+ const code = detail.error_code;
1277
+ return typeof code === "string" && SAFE_ERROR_CODE_RE.test(code) ? code : null;
1278
+ }
1279
+ /**
1280
+ * POST `.../tickets/{ticket_key}/continue` exactly once and classify the answer.
1281
+ *
1282
+ * Never throws for an HTTP or transport outcome — every one of them is returned
1283
+ * as a {@link ContinuationPostOutcome}, so no caller can accidentally turn a
1284
+ * timeout into "refused" by catching an exception. Never falls back to unpark.
1285
+ * The API key travels only in the header; the body carries no credential and no
1286
+ * caller-asserted authority.
1287
+ */
1288
+ export async function postContinueParkedChild(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
1289
+ const b = request.bindings;
1290
+ requireNonEmptyString(b.epicRunId);
1291
+ requireNonEmptyString(b.ticketKey);
1292
+ const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(b.epicRunId, b.ticketKey)}/continue`);
1293
+ const body = JSON.stringify({
1294
+ repo_name: b.repoName,
1295
+ request_id: request.requestId,
1296
+ parked_gate: b.parkedGate,
1297
+ expected_row_version: b.expectedRowVersion,
1298
+ expected_head_sha: b.expectedHeadSha,
1299
+ source_park_generation: b.sourceParkGeneration,
1300
+ request_fingerprint: request.requestFingerprint,
1301
+ ...(request.reason ? { reason: request.reason } : {}),
1302
+ });
1303
+ const controller = new AbortController();
1304
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1305
+ try {
1306
+ let resp;
1307
+ try {
1308
+ resp = await fetchImpl(url, {
1309
+ method: "POST",
1310
+ headers: conductorPostHeaders(access),
1311
+ body,
1312
+ signal: controller.signal,
1313
+ });
1314
+ }
1315
+ catch {
1316
+ return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
1317
+ }
1318
+ let parsed;
1319
+ let parsedOk = true;
1320
+ try {
1321
+ parsed = await resp.json();
1322
+ }
1323
+ catch {
1324
+ parsedOk = false;
1325
+ }
1326
+ if (TYPED_CONTINUATION_STATUSES.has(resp.status) && parsedOk) {
1327
+ try {
1328
+ return { kind: "result", status: resp.status, result: parseContinueParkedChildResult(parsed) };
1329
+ }
1330
+ catch {
1331
+ // Fall through: an untyped body on these statuses is classified below.
1332
+ }
1333
+ }
1334
+ if (resp.status >= 200 && resp.status < 300) {
1335
+ return { kind: "uncertain", reason: "malformed", status: resp.status };
1336
+ }
1337
+ if (resp.status >= 500) {
1338
+ return { kind: "uncertain", reason: "server", status: resp.status };
1339
+ }
1340
+ // A 4xx without a typed body is an authoritative pre-mutation rejection.
1341
+ return {
1342
+ kind: "refused_http",
1343
+ status: resp.status,
1344
+ errorCode: parsedOk ? safeDetailErrorCode(parsed) : null,
1345
+ };
1346
+ }
1347
+ finally {
1348
+ clearTimeout(timer);
1349
+ }
1350
+ }
1351
+ /**
1352
+ * GET `.../tickets/{ticket_key}/continuation` — the child's current episode and,
1353
+ * with `requestId`, that request's receipt. Read-only; bounded by the standard
1354
+ * conductor timeout. Throws {@link ConductorBridgeApiError} on transport or HTTP
1355
+ * failure and {@link ContinuationParseError} on a malformed body.
1356
+ */
1357
+ export async function fetchChildContinuationStatus(access, query, fetchImpl = globalThis.fetch) {
1358
+ requireNonEmptyString(query.epicRunId);
1359
+ requireNonEmptyString(query.ticketKey);
1360
+ const params = { repo_name: access.repoName };
1361
+ if (query.requestId)
1362
+ params.request_id = query.requestId;
1363
+ const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(query.epicRunId, query.ticketKey)}/continuation`, params);
1364
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1365
+ return parseContinuationStatusResult(parsed);
1366
+ }
1254
1367
  /**
1255
1368
  * POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
1256
1369
  * — the recovery lane for a human/external push that drifted the PR head off the
@@ -1441,8 +1554,13 @@ const REPAIR_TOKEN_RE = /^[a-z0-9_.:-]{1,120}$/;
1441
1554
  const POLICY_DIGEST_TOKEN_RE = /^policy:[0-9a-f]{12}$/;
1442
1555
  /** A dotted policy-path NAME. Never a value. */
1443
1556
  const POLICY_PATH_RE = /^[A-Za-z0-9_-]{1,100}(?:\.[A-Za-z0-9_-]{1,100}){0,15}$/;
1444
- /** `resume-run`'s closed run-control state vocabulary. */
1445
- const RESUME_RUN_STATES = ["paused", "quarantined", "none"];
1557
+ /**
1558
+ * `resume-run`'s closed PRIOR-state vocabulary. `blocked` is the qualifying
1559
+ * integration block the run-addressed route may lift; it is never a resulting state.
1560
+ */
1561
+ const RESUME_RUN_BEFORE_STATES = ["blocked", "paused", "quarantined", "none"];
1562
+ /** `resume-run`'s closed RESULTING-state vocabulary: a resume always leaves no control. */
1563
+ const RESUME_RUN_AFTER_STATES = ["none"];
1446
1564
  function requireOnlyRepairKeys(p, extraKeys) {
1447
1565
  for (const key of Object.keys(p)) {
1448
1566
  if (!REPAIR_BASE_KEYS.includes(key) && !extraKeys.includes(key)) {
@@ -1509,18 +1627,29 @@ function parseResumeRunResult(parsed) {
1509
1627
  requireOnlyRepairKeys(p, []);
1510
1628
  const before = p["before"];
1511
1629
  const after = p["after"];
1630
+ const committed = p["committed"];
1631
+ const eventRecorded = p["event_recorded"];
1512
1632
  if (typeof before !== "string" ||
1513
- !RESUME_RUN_STATES.includes(before) ||
1633
+ !RESUME_RUN_BEFORE_STATES.includes(before) ||
1514
1634
  typeof after !== "string" ||
1515
- !RESUME_RUN_STATES.includes(after)) {
1635
+ !RESUME_RUN_AFTER_STATES.includes(after)) {
1636
+ throw new ConductorBridgeApiError("server");
1637
+ }
1638
+ // Exactly two families: an applied resume (`blocked`/`paused`/`quarantined` →
1639
+ // `none`, committed, either audit outcome) or the already-current no-op
1640
+ // (`none` → `none`, nothing committed, no event). Any other combination is
1641
+ // unconfirmed, never coerced into a result.
1642
+ const applied = before !== "none" && committed;
1643
+ const noOp = before === "none" && !committed && !eventRecorded;
1644
+ if (!applied && !noOp) {
1516
1645
  throw new ConductorBridgeApiError("server");
1517
1646
  }
1518
1647
  return {
1519
1648
  epic_run_id: p["epic_run_id"],
1520
1649
  before,
1521
1650
  after,
1522
- committed: p["committed"],
1523
- event_recorded: p["event_recorded"],
1651
+ committed,
1652
+ event_recorded: eventRecorded,
1524
1653
  };
1525
1654
  }
1526
1655
  /**
@@ -2333,6 +2462,20 @@ function requireNullableString(o, key) {
2333
2462
  throw readinessShapeError();
2334
2463
  return v;
2335
2464
  }
2465
+ /**
2466
+ * BAPI-1279 — the executor coverage fields. Each is omitted when the server did
2467
+ * not send it (an older deploy), and a present non-boolean fails CLOSED to
2468
+ * `false`: coverage that cannot be read is coverage that is not confirmed.
2469
+ */
2470
+ function parseOptionalExecutorCoverage(o) {
2471
+ const out = {};
2472
+ for (const key of ["process_heartbeat_fresh", "repo_wide_executor_live"]) {
2473
+ if (!(key in o))
2474
+ continue;
2475
+ out[key] = o[key] === true;
2476
+ }
2477
+ return out;
2478
+ }
2336
2479
  function requireNullableBool(o, key) {
2337
2480
  const v = o[key];
2338
2481
  if (v === null || v === undefined)
@@ -2519,6 +2662,7 @@ export function parseConductorReadinessResponse(body) {
2519
2662
  last_seen_at: requireNullableString(exec, "last_seen_at"),
2520
2663
  last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
2521
2664
  ready: requireNullableBool(exec, "ready"),
2665
+ ...parseOptionalExecutorCoverage(exec),
2522
2666
  },
2523
2667
  thresholds: {
2524
2668
  reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
@@ -2704,8 +2848,9 @@ function parseConductorCiWorkflow(body) {
2704
2848
  *
2705
2849
  * `notify_webhook_default_verified` is the one tri-state: absent is coerced to
2706
2850
  * `null` rather than to `false`, because "not verified here" and "verified as
2707
- * broken" are different statements and only one of them is evidence. Every other
2708
- * field is a strict boolean and a wrong type throws.
2851
+ * broken" are different statements and only one of them is evidence.
2852
+ * `escalation_email_available` (BAPI-1274) is lenient: anything but `true` is
2853
+ * `false`. Every other field is a strict boolean and a wrong type throws.
2709
2854
  */
2710
2855
  function parseUnattended(body) {
2711
2856
  const raw = body.unattended;
@@ -2719,6 +2864,10 @@ function parseUnattended(body) {
2719
2864
  ? null
2720
2865
  : requireNullableBool(o, "notify_webhook_default_verified"),
2721
2866
  repository_readiness_confirmed: requireBool(o, "repository_readiness_confirmed"),
2867
+ // BAPI-1274 — optional and lenient: absent, null, or any non-boolean
2868
+ // normalizes to `false` rather than throwing, because a server older than
2869
+ // BAPI-1274 omits it and must keep today's behavior.
2870
+ escalation_email_available: o.escalation_email_available === true,
2722
2871
  };
2723
2872
  }
2724
2873
  /**
@@ -2735,6 +2884,15 @@ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fet
2735
2884
  const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
2736
2885
  return parseConductorReadinessResponse(body);
2737
2886
  }
2887
+ const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
2888
+ function optionalRowOutcome(o, key) {
2889
+ const v = o[key];
2890
+ if (v === undefined || v === null)
2891
+ return null;
2892
+ if (typeof v !== "string" || !ROW_OUTCOMES.has(v))
2893
+ throw readinessShapeError();
2894
+ return v;
2895
+ }
2738
2896
  /** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
2739
2897
  export function parseConductorSupervisorBootstrapResponse(body) {
2740
2898
  const o = requireObject(body);
@@ -2749,6 +2907,8 @@ export function parseConductorSupervisorBootstrapResponse(body) {
2749
2907
  repo_name: repoName,
2750
2908
  setup_written: requireBool(o, "setup_written"),
2751
2909
  config_written: requireBool(o, "config_written"),
2910
+ setup_outcome: optionalRowOutcome(o, "setup_outcome"),
2911
+ config_outcome: optionalRowOutcome(o, "config_outcome"),
2752
2912
  setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
2753
2913
  config_source: requireEnum(o, "config_source", READINESS_SOURCES),
2754
2914
  setup_created_at: requireNullableString(o, "setup_created_at"),
@@ -2762,14 +2922,167 @@ export function parseConductorSupervisorBootstrapResponse(body) {
2762
2922
  /**
2763
2923
  * POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
2764
2924
  *
2765
- * The ONLY write this client performs. Since BAPI-694 it may also carry the
2766
- * optional repository-default `review_policy`; omitting that field stores no
2767
- * default rather than a neutral one.
2925
+ * Since BAPI-694 it may also carry the optional repository-default
2926
+ * `review_policy`; omitting that field keeps the stored default. Since BAPI-1288
2927
+ * a relaxation is refused with 409 unless `approval` carries the exact token a
2928
+ * {@link previewConductorSupervisorBootstrap} of the SAME request returned.
2929
+ * A 409 is never retried here — see {@link classifySupervisorBootstrapFailure}.
2768
2930
  */
2769
2931
  export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
2770
2932
  const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
2771
2933
  repo_name: access.repoName,
2772
2934
  });
2773
- const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
2935
+ const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
2774
2936
  return parseConductorSupervisorBootstrapResponse(body);
2775
2937
  }
2938
+ // ---------------------------------------------------------------------------
2939
+ // BAPI-1288 — preview of a protected project-default change
2940
+ // ---------------------------------------------------------------------------
2941
+ /** Closed vocabularies of a server-authored gate-change effect. */
2942
+ const GATE_CHANGE_REQUIREMENTS = new Set([
2943
+ "done_gate",
2944
+ "required_ci_checks",
2945
+ "done_gate_review",
2946
+ "review_policy_default",
2947
+ ]);
2948
+ const GATE_CHANGE_KINDS = new Set(["removed", "weakened", "replaced"]);
2949
+ const GATE_OBLIGATIONS = new Set([
2950
+ "ci_check_green",
2951
+ "sticky_verdict_approved",
2952
+ "native_review_approved",
2953
+ "approval_minimum",
2954
+ "verdictless_review_parks",
2955
+ ]);
2956
+ const CHECK_REF_RE = /^ci-[0-9a-f]{12}$/;
2957
+ /** Bounds the client enforces on server text before it may be displayed. */
2958
+ const MAX_EFFECTS = 16;
2959
+ const MAX_DESCRIPTION_LENGTH = 300;
2960
+ const MAX_CHECK_REFS = 50;
2961
+ const MAX_APPROVAL_TOKEN_LENGTH = 1024;
2962
+ function requireObligationList(o, key) {
2963
+ const v = o[key];
2964
+ if (!Array.isArray(v) || v.length > GATE_OBLIGATIONS.size)
2965
+ throw readinessShapeError();
2966
+ for (const item of v) {
2967
+ if (typeof item !== "string" || !GATE_OBLIGATIONS.has(item))
2968
+ throw readinessShapeError();
2969
+ }
2970
+ return v;
2971
+ }
2972
+ function parseGateChangeEffect(raw) {
2973
+ const o = requireObject(raw);
2974
+ const refs = o.removed_ci_check_refs;
2975
+ if (!Array.isArray(refs) ||
2976
+ refs.length > MAX_CHECK_REFS ||
2977
+ refs.some((r) => typeof r !== "string" || !CHECK_REF_RE.test(r))) {
2978
+ throw readinessShapeError();
2979
+ }
2980
+ const description = o.description;
2981
+ if (typeof description !== "string" ||
2982
+ description.length === 0 ||
2983
+ description.length > MAX_DESCRIPTION_LENGTH ||
2984
+ /[\u0000-\u001F\u007F]/.test(description)) {
2985
+ throw readinessShapeError();
2986
+ }
2987
+ const count = requireInt(o, "removed_ci_check_count");
2988
+ if (count < 0)
2989
+ throw readinessShapeError();
2990
+ return {
2991
+ row: requireEnum(o, "row", new Set(["setup", "config"])),
2992
+ requirement: requireEnum(o, "requirement", GATE_CHANGE_REQUIREMENTS),
2993
+ change: requireEnum(o, "change", GATE_CHANGE_KINDS),
2994
+ removed_obligations: requireObligationList(o, "removed_obligations"),
2995
+ replacement_obligations: requireObligationList(o, "replacement_obligations"),
2996
+ removed_ci_check_count: count,
2997
+ removed_ci_check_refs: refs,
2998
+ stored_min_approvals: requireNullableInt(o, "stored_min_approvals"),
2999
+ proposed_min_approvals: requireNullableInt(o, "proposed_min_approvals"),
3000
+ description,
3001
+ };
3002
+ }
3003
+ /**
3004
+ * Validate a raw preview body, fail-closed.
3005
+ *
3006
+ * Every field is checked against the server's closed contract; anything else —
3007
+ * an unknown outcome, an effect category this client does not know, an
3008
+ * over-long or control-character description, a token without
3009
+ * `approval_required`, a writer or repository that does not match the request —
3010
+ * is a shape error, so an unbounded server string can never reach the operator's
3011
+ * terminal as if it were a vetted description.
3012
+ */
3013
+ export function parseConductorSupervisorBootstrapPreview(body, expectedRepoName) {
3014
+ const o = requireObject(body);
3015
+ if (o.repo_name !== expectedRepoName)
3016
+ throw readinessShapeError();
3017
+ if (o.writer !== "bootstrap")
3018
+ throw readinessShapeError();
3019
+ const names = o.changed_field_names;
3020
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || n.length > 100)) {
3021
+ throw readinessShapeError();
3022
+ }
3023
+ const effectsRaw = o.effects;
3024
+ if (!Array.isArray(effectsRaw) || effectsRaw.length > MAX_EFFECTS)
3025
+ throw readinessShapeError();
3026
+ const effects = effectsRaw.map(parseGateChangeEffect);
3027
+ const approvalRequired = requireBool(o, "approval_required");
3028
+ const token = requireNullableString(o, "approval_token");
3029
+ if (approvalRequired !== effects.length > 0)
3030
+ throw readinessShapeError();
3031
+ if (approvalRequired !== (token !== null))
3032
+ throw readinessShapeError();
3033
+ if (token !== null && (token.length === 0 || token.length > MAX_APPROVAL_TOKEN_LENGTH)) {
3034
+ throw readinessShapeError();
3035
+ }
3036
+ return {
3037
+ repo_name: expectedRepoName,
3038
+ writer: "bootstrap",
3039
+ setup_outcome: requireEnum(o, "setup_outcome", ROW_OUTCOMES),
3040
+ config_outcome: requireEnum(o, "config_outcome", ROW_OUTCOMES),
3041
+ changed_field_names: names,
3042
+ approval_required: approvalRequired,
3043
+ effects,
3044
+ approval_token: token,
3045
+ };
3046
+ }
3047
+ /**
3048
+ * POST `/jira/epic-runs/supervisor-bootstrap/preview?repo_name=<repo>` — what a
3049
+ * bootstrap with `request` WOULD do. Read-only on the server.
3050
+ *
3051
+ * Send the same body to {@link bootstrapConductorSupervisorDefaults} afterwards
3052
+ * (plus `approval` when the preview required it); any difference makes the
3053
+ * server's token check fail with a fresh-preview conflict.
3054
+ */
3055
+ export async function previewConductorSupervisorBootstrap(access, request, fetchImpl) {
3056
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap/preview`, { repo_name: access.repoName });
3057
+ const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
3058
+ return parseConductorSupervisorBootstrapPreview(body, access.repoName);
3059
+ }
3060
+ const GATE_CHANGE_CODE_TO_KIND = Object.freeze({
3061
+ APPROVAL_REQUIRED: "approval-required",
3062
+ STALE_PREVIEW: "stale-preview",
3063
+ UNKNOWN_STORED_STATE: "unknown-stored-state",
3064
+ NON_RELAXABLE: "non-relaxable",
3065
+ INVALID_PROPOSAL: "invalid-proposal",
3066
+ STATE_UNAVAILABLE: "state-unavailable",
3067
+ });
3068
+ export function classifySupervisorBootstrapFailure(err) {
3069
+ if (!(err instanceof ConductorBridgeApiError))
3070
+ return "network";
3071
+ const mapped = err.errorCode ? GATE_CHANGE_CODE_TO_KIND[err.errorCode] : undefined;
3072
+ if (mapped)
3073
+ return mapped;
3074
+ if (err.kind === "invalid-input")
3075
+ return "unsupported-protocol";
3076
+ if (err.kind === "unauthorized")
3077
+ return "unauthorized";
3078
+ if (err.kind === "timeout")
3079
+ return "timeout";
3080
+ if (err.kind === "network")
3081
+ return "network";
3082
+ if (err.status === 404 || err.status === 405 || err.status === 422) {
3083
+ return "unsupported-protocol";
3084
+ }
3085
+ if (err.kind === "server")
3086
+ return "server";
3087
+ return "rejected";
3088
+ }
@@ -35,7 +35,9 @@ export const WORKER_NOT_PRESENT_REMEDIATION = "start a worker (`python worker.py
35
35
  export const SECOND_RECONCILER_REMEDIATION = "find and stop the extra reconciler: check `ps aux | grep worker.py` for a reparented " +
36
36
  `survivor of an earlier plane (${BRING_UP_RUNBOOK} §6), and note that a dev server started ` +
37
37
  "without DISABLE_SCHEDULER=true runs a reconciler too. A reconciler stopped in the last 120 " +
38
- "seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry.";
38
+ "seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry. " +
39
+ "On a hosted deployment, check that exactly one worker process is scaled (for example one " +
40
+ "Heroku `worker` dyno): each worker runs its own reconciler.";
39
41
  // ---------------------------------------------------------------------------
40
42
  // Classification
41
43
  // ---------------------------------------------------------------------------
@@ -82,8 +82,9 @@ export const MCP_PROFILE_UNSET = "(unset)";
82
82
  /**
83
83
  * The CLI-only verbs that call the Bridge API from a shell-spawned process and
84
84
  * therefore resolve credentials themselves (CLAUDE.md "Shell-Spawned Credential
85
- * Resolution"): the four BAPI-872 recovery verbs and the six run-addressed repair
86
- * verbs (BAPI-1154's three and BAPI-1176's three). `.mcp.json` env is invisible to
85
+ * Resolution"): the four BAPI-872 recovery verbs, the six run-addressed repair
86
+ * verbs (BAPI-1154's three and BAPI-1176's three), and BAPI-1300's parked-child
87
+ * continuation pair. `.mcp.json` env is invisible to
87
88
  * them — only `BAPI_API_KEY` in the parent env and the user-scoped credential
88
89
  * store count.
89
90
  */
@@ -98,6 +99,9 @@ export const CREDENTIALED_RECOVERY_COMMANDS = [
98
99
  "scope-catch-up",
99
100
  "patch-policy",
100
101
  "resume-run",
102
+ // BAPI-1300: parked-child continuation and its read-only receipt lookup.
103
+ "continue-child",
104
+ "continuation-receipt",
101
105
  ];
102
106
  /** Remediation for recovery/repair verbs that cannot resolve Bridge credentials. */
103
107
  export const REPAIR_CREDENTIAL_REMEDIATION = "export BAPI_API_KEY in the shell that runs `conductor`, or store a credential for this " +
@@ -803,11 +807,14 @@ export function formatConductorDoctorReport(report) {
803
807
  lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
804
808
  lines.push(`degraded: ${git_hooks.degraded}`);
805
809
  for (const hook of git_hooks.hooks) {
806
- // BAPI-772: the embedded target is shown alongside the structural fields —
807
- // a managed block whose binary no longer exists is a DEAD hook, and this line
808
- // is where that becomes visible. Advisory only: never changes the exit code.
810
+ // BAPI-772 made a dead embedded target visible here; BAPI-1290 replaces the
811
+ // raw path with the shared per-hook verdict, because a file that EXISTS can
812
+ // still be a prunable cache entry or a runtime whose native binding does not
813
+ // load. The embedded path stays in `--json` output only. Advisory only:
814
+ // never changes the exit code. Read defensively — a literal fixture may
815
+ // predate `state`/`usable`, and unknown is never healthy.
809
816
  lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present} ` +
810
- `bin=${hook.embedded_bin_path ?? "n/a"} bin_is_file=${hook.embedded_bin_is_file}`);
817
+ `state=${hook.state ?? "unknown"} usable=${hook.usable === true}`);
811
818
  }
812
819
  if (git_hooks.warnings.length > 0) {
813
820
  lines.push("git hook warnings:");
@@ -962,8 +969,8 @@ export const CONDUCTOR_READINESS_DESCRIPTORS = [
962
969
  export const CONDUCTOR_LEDGER_REMEDIATION = "run `epic-implementer doctor` for the ledger detail, then re-create the local ledger directory with " +
963
970
  "owner-only permissions; the ledger is local observability and never blocks a run.";
964
971
  /** Remediation for degraded or missing managed git hooks. */
965
- export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` to install the managed post-commit and reference-transaction " +
966
- "hooks; they are local, opportunistic, and bypassable.";
972
+ export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` from a persistent install to install the managed post-commit and " +
973
+ "reference-transaction hooks; they are local, opportunistic, and bypassable, and never gate engine admission.";
967
974
  /** Remediation for a Claude login this host cannot confirm. */
968
975
  export const CONDUCTOR_CLAUDE_LOGIN_REMEDIATION = "run `claude login` on the host that will run conductor workers; Bridge stores no Anthropic " +
969
976
  "credential and can only advise here.";
@@ -1014,22 +1021,24 @@ function gitHooksOutcome(report) {
1014
1021
  };
1015
1022
  }
1016
1023
  // Hook NAMES and booleans only — `hooks_dir` is absolute and never projected.
1017
- const rendered = hooks.hooks
1018
- .map((hook) => `${hook.name}=${hook.exists && hook.managed_block_present}`)
1019
- .join(", ");
1020
- return hooks.degraded
1024
+ // BAPI-1290: the boolean is the shared per-hook `usable` verdict (qualified
1025
+ // persistent runtime, loadable native binding), not mere file presence; a
1026
+ // hook without that verdict is unknown, and unknown is never healthy.
1027
+ const rendered = hooks.hooks.map((hook) => `${hook.name}=${hook.usable === true}`).join(", ");
1028
+ const allUsable = hooks.hooks.length > 0 && hooks.hooks.every((hook) => hook.usable === true);
1029
+ return hooks.degraded || !allUsable
1021
1030
  ? {
1022
1031
  id: "git-hooks",
1023
1032
  label: "Conductor git hooks",
1024
1033
  status: "warn",
1025
- detail: `managed hooks installed: ${rendered}`,
1034
+ detail: `managed hooks usable: ${rendered}`,
1026
1035
  remediation: CONDUCTOR_GIT_HOOKS_REMEDIATION,
1027
1036
  }
1028
1037
  : {
1029
1038
  id: "git-hooks",
1030
1039
  label: "Conductor git hooks",
1031
1040
  status: "pass",
1032
- detail: `managed hooks installed: ${rendered}`,
1041
+ detail: `managed hooks usable: ${rendered}`,
1033
1042
  };
1034
1043
  }
1035
1044
  /**