@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -90,9 +90,49 @@ const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
90
90
  * backend message that happens to echo a token/header never reaches a log line.
91
91
  */
92
92
  function redactErrorPreview(text) {
93
- return text
93
+ return (text
94
94
  .replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
95
- .replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]");
95
+ .replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]")
96
+ // BAPI-754: the notification webhook URL is the run policy's one
97
+ // secret-bearing value (it can carry userinfo credentials or a query
98
+ // token). `stripWebhookUrlsDeep` removes it structurally before extraction,
99
+ // which handles every body the server actually emits; these two patterns are
100
+ // the textual backstop for a message that INTERPOLATED the URL into prose
101
+ // rather than carrying it as its own JSON leaf.
102
+ //
103
+ // Both are deliberately narrow — they match only a URL-shaped value. A
104
+ // broader "everything after webhook_url" rule would eat the field-path
105
+ // messages a 422 exists to deliver ("policy_json.notify.webhook_url: Input
106
+ // should be a valid URL"), leaving the operator with a redaction where their
107
+ // actionable error used to be.
108
+ .replace(/(webhook[_-]?url)\b["']?\s*[:=]?\s*["']?https?:\/\/\S+/gi, "$1 [REDACTED]")
109
+ // A URL carrying userinfo credentials is unsafe wherever it appears.
110
+ .replace(/https?:\/\/[^\s/@]+:[^\s/@]+@\S+/gi, "[REDACTED]"));
111
+ }
112
+ /**
113
+ * Return a deep copy of a parsed error body with every `webhook_url` key removed,
114
+ * at any depth.
115
+ *
116
+ * Structural removal, not pattern matching: a regex over the rendered JSON would
117
+ * depend on the exact serialization of a value we do not control, and a URL that
118
+ * failed to match would be printed in full. Removing the KEY cannot miss.
119
+ * Non-object input is returned unchanged, and cyclic input is left alone rather
120
+ * than throwing — this runs on an error path and must never raise.
121
+ */
122
+ function stripWebhookUrlsDeep(value, depth = 0) {
123
+ if (depth > 12)
124
+ return undefined;
125
+ if (Array.isArray(value))
126
+ return value.map((item) => stripWebhookUrlsDeep(item, depth + 1));
127
+ if (!value || typeof value !== "object")
128
+ return value;
129
+ const out = {};
130
+ for (const [key, item] of Object.entries(value)) {
131
+ if (key === "webhook_url")
132
+ continue;
133
+ out[key] = stripWebhookUrlsDeep(item, depth + 1);
134
+ }
135
+ return out;
96
136
  }
97
137
  /** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
98
138
  function boundedErrorPreview(text) {
@@ -201,7 +241,7 @@ function redactDiagnosticValues(diagnostics, secrets) {
201
241
  */
202
242
  async function readSanitizedErrorDiagnostics(resp, headers = {}) {
203
243
  try {
204
- const diagnostics = extractSanitizedErrorDiagnostics(await resp.json());
244
+ const diagnostics = extractSanitizedErrorDiagnostics(stripWebhookUrlsDeep(await resp.json()));
205
245
  const secrets = Object.entries(headers)
206
246
  .filter(([k]) => /key|authorization|token/i.test(k))
207
247
  .map(([, v]) => v);
@@ -435,6 +475,21 @@ function conductorPostHeaders(access) {
435
475
  * POST and PATCH wrappers below.
436
476
  */
437
477
  async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
478
+ const { body: parsed } = await fetchConductorJsonWithMethodStatusAndTimeout(method, url, headers, body, timeoutMs, fetchImpl);
479
+ return parsed;
480
+ }
481
+ /**
482
+ * Same transport as {@link fetchConductorJsonWithMethodAndTimeout}, but returns
483
+ * the successful response's HTTP status alongside the parsed body.
484
+ *
485
+ * BAPI-754: `POST /jira/epic-runs/runs` is idempotent and answers `201` for a
486
+ * fresh run and `200` for a reused one. That distinction is the ONLY signal
487
+ * separating "the policy I sent is now in force" from "a policy I did not send is
488
+ * already in force", and the body is byte-identical in both cases — so a caller
489
+ * that only sees the body has to issue another request to learn which happened.
490
+ * Success statuses only; every non-2xx path still throws exactly as before.
491
+ */
492
+ async function fetchConductorJsonWithMethodStatusAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
438
493
  const controller = new AbortController();
439
494
  const timer = setTimeout(() => controller.abort(), timeoutMs);
440
495
  try {
@@ -456,7 +511,7 @@ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body
456
511
  throw new ConductorBridgeApiError("http", resp.status, diagnostics);
457
512
  }
458
513
  try {
459
- return await resp.json();
514
+ return { status: resp.status, body: await resp.json() };
460
515
  }
461
516
  catch {
462
517
  throw new ConductorBridgeApiError("network");
@@ -737,6 +792,67 @@ export async function createEpicRun(access, request, fetchImpl = globalThis.fetc
737
792
  const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
738
793
  return parsed;
739
794
  }
795
+ /**
796
+ * The same create call as {@link createEpicRun}, plus the disposition the HTTP
797
+ * status carries.
798
+ *
799
+ * `created: true` means HTTP `201` — a fresh run, so any `policyJson` sent is now
800
+ * the run's policy. `created: false` means HTTP `200` — the server returned an
801
+ * existing live run and IGNORED the submitted policy, because create is
802
+ * idempotent and never retargets a run. A caller that supplied a policy must
803
+ * therefore reconcile `run.policy_json` (already server-redacted) against what it
804
+ * asked for rather than assume it took effect.
805
+ *
806
+ * Added as a separate export rather than by widening {@link createEpicRun}'s
807
+ * return type, so existing callers that only need the record are untouched.
808
+ */
809
+ export async function createEpicRunWithDisposition(access, request, fetchImpl = globalThis.fetch) {
810
+ requireNonEmptyString(request.epicKey);
811
+ const body = {
812
+ repo_name: access.repoName,
813
+ epic_key: request.epicKey,
814
+ status: request.status ?? "planning",
815
+ current_plan_version: request.currentPlanVersion ?? 0,
816
+ };
817
+ if (request.policyJson !== undefined)
818
+ body.policy_json = request.policyJson;
819
+ if (request.budgetWallClockSeconds !== undefined) {
820
+ body.budget_wall_clock_seconds = request.budgetWallClockSeconds;
821
+ }
822
+ if (request.budgetCostCents !== undefined) {
823
+ body.budget_cost_cents = request.budgetCostCents;
824
+ }
825
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`);
826
+ const { status, body: parsed } = await fetchConductorJsonWithMethodStatusAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
827
+ return { run: parsed, created: status === 201 };
828
+ }
829
+ /**
830
+ * PATCH `/jira/epic-runs/runs/{identifier}` with a COMPLETE replacement of the
831
+ * operator-managed run policy.
832
+ *
833
+ * The endpoint's `policy_json` is replace-not-merge: keys absent from the request
834
+ * are removed and revert to resolver defaults. That is exactly the semantic a
835
+ * `--replace-policy` operator wants — the policy file is the whole policy — and it
836
+ * is why this helper sends the composed object verbatim instead of reading the
837
+ * stored policy and merging into it. A read-modify-write here would silently
838
+ * resurrect keys the operator deleted from their file.
839
+ *
840
+ * Two server-retained paths are deliberately NOT sent back:
841
+ * `notify.webhook_url` (absent from every response, so a client has nothing to
842
+ * echo; omitting it retains the stored URL) and the server-minted
843
+ * `feature_branch_provisioning` (retained automatically, and re-authoring it is a
844
+ * provenance conflict the server rejects).
845
+ */
846
+ export async function replaceEpicRunPolicy(access, request, fetchImpl = globalThis.fetch) {
847
+ requireNonEmptyString(request.epicRunId);
848
+ const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicRunId));
849
+ const body = JSON.stringify({
850
+ repo_name: access.repoName,
851
+ policy_json: request.policyJson,
852
+ });
853
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
854
+ return parsed;
855
+ }
740
856
  /**
741
857
  * PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
742
858
  * lifecycle status. Drives the same backend `update_epic_run` CAS path the
@@ -985,6 +1101,15 @@ export async function reconcileShadowMerge(access, request, fetchImpl = globalTh
985
1101
  * authoritative commit-watermark verdict for one ready ticket. The caller treats
986
1102
  * `covered`/`not_applicable` as dispatchable, `stale` as hold, and `failed` as a
987
1103
  * terminal shadow failure (the backend has already persisted the blocked state).
1104
+ *
1105
+ * BAPI-678: `stale` now covers two backend situations, and deliberately does not
1106
+ * distinguish them on the wire — ordinary unproven freshness, and a `failed`
1107
+ * shadow lifecycle DOWNGRADED by the backend's `policy_json.shadow.warn_only`
1108
+ * posture. Both mean exactly the same thing to this caller (hold, do not claim a
1109
+ * dispatch key), which is why no new verdict was introduced. The backend also
1110
+ * bounds the warn-only hold with its own stale deadline and parks the ticket if
1111
+ * it expires, so a hold here is not unbounded even though nothing on this side
1112
+ * tracks its age. See {@link ShadowFreshnessVerdict}.
988
1113
  */
989
1114
  export async function fetchShadowDispatchFreshness(access, request, fetchImpl = globalThis.fetch) {
990
1115
  requireNonEmptyString(request.epicKey);
@@ -995,6 +1120,64 @@ export async function fetchShadowDispatchFreshness(access, request, fetchImpl =
995
1120
  const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
996
1121
  return parseShadowDispatchFreshnessResult(parsed);
997
1122
  }
1123
+ /**
1124
+ * POST `/jira/epic-runs/plan/validate` to validate a plan blob without storing it.
1125
+ *
1126
+ * The point of the call is what it does NOT do: no run is created, no plan is
1127
+ * stored, and no automation-start debit is charged. `setup-epic` runs this before
1128
+ * every create so a plan the server would reject costs an operator nothing.
1129
+ *
1130
+ * Error handling is load-bearing for the CLI's branching, so it is spelled out:
1131
+ *
1132
+ * - A **`404`** surfaces as `ConductorBridgeApiError.status === 404` and means the
1133
+ * server predates this lane. That is the ONE case the CLI is allowed to fall
1134
+ * back from, so it must stay distinguishable from a validation failure.
1135
+ * - A **transport failure** throws with NO `status`, which is how the CLI knows to
1136
+ * say "server unreachable, cannot fully validate" rather than blaming the plan.
1137
+ * - A **`400`** is the plan's own field-named rejection and is terminal.
1138
+ *
1139
+ * A nominal `200` whose body does not match the strict success shape is treated as
1140
+ * a protocol error rather than a partial success: reporting "valid" on a body we
1141
+ * could not fully verify is precisely the false assurance this lane exists to
1142
+ * remove.
1143
+ */
1144
+ export async function validateEpicPlan(access, request, fetchImpl = globalThis.fetch) {
1145
+ requirePositiveSafeInteger(request.planVersion);
1146
+ const blobVersion = request.planBlob.plan_version;
1147
+ if (blobVersion !== undefined && blobVersion !== request.planVersion) {
1148
+ throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
1149
+ }
1150
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/plan/validate`);
1151
+ const body = {
1152
+ repo_name: access.repoName,
1153
+ plan_version: request.planVersion,
1154
+ plan_blob: request.planBlob,
1155
+ };
1156
+ if (request.epicKey !== undefined)
1157
+ body.epic_key = request.epicKey;
1158
+ const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1159
+ return parseValidateEpicPlanResult(parsed);
1160
+ }
1161
+ /** Strict parse of the validate lane's success body; throws on any deviation. */
1162
+ function parseValidateEpicPlanResult(parsed) {
1163
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1164
+ throw new ConductorBridgeApiError("server");
1165
+ }
1166
+ const p = parsed;
1167
+ const planHash = p["plan_hash"];
1168
+ const serializationEnabled = p["serialization_enabled"];
1169
+ const insertedEdges = p["inserted_edges"];
1170
+ if (p["valid"] !== true ||
1171
+ typeof planHash !== "string" ||
1172
+ planHash.trim() === "" ||
1173
+ typeof serializationEnabled !== "boolean" ||
1174
+ typeof insertedEdges !== "number" ||
1175
+ !Number.isSafeInteger(insertedEdges) ||
1176
+ insertedEdges < 0) {
1177
+ throw new ConductorBridgeApiError("server");
1178
+ }
1179
+ return { planHash, serializationEnabled, insertedEdges };
1180
+ }
998
1181
  /**
999
1182
  * POST the immutable plan blob to the durable-store endpoint. The blob is
1000
1183
  * written once per `(epic_run_id, plan_version)` and never mutated — a
@@ -1019,6 +1202,38 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
1019
1202
  });
1020
1203
  return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1021
1204
  }
1205
+ /**
1206
+ * PATCH one approved plan node's `ticket_spec` without re-storing or re-approving
1207
+ * the plan (BAPI-722).
1208
+ *
1209
+ * This is how a ticket specification too large for Jira's 32,767-character
1210
+ * description cap reaches the reviewer intact: it is stored on the approved plan
1211
+ * node, which the reconciler already consults FIRST when resolving a spec-review
1212
+ * prompt. Nothing else about the run changes — not the plan version, not the
1213
+ * approval pointers, and not a single `epic_ticket_status` row — which is what
1214
+ * makes it safe against a live epic.
1215
+ *
1216
+ * `expectedPlanHash` is the optimistic-concurrency token; a stale token on a real
1217
+ * content change returns HTTP 409. The API key travels ONLY in the `X-API-Key`
1218
+ * header, and neither the spec text nor any credential is ever incorporated into a
1219
+ * thrown error message. Errors surface as a sanitized
1220
+ * {@link ConductorBridgeApiError}.
1221
+ */
1222
+ export async function updateApprovedPlanNodeTicketSpec(access, request, fetchImpl = globalThis.fetch) {
1223
+ requireNonEmptyString(request.epicKey);
1224
+ requireNonEmptyString(request.ticketKey);
1225
+ requireNonEmptyString(request.expectedPlanHash);
1226
+ requireNonEmptyString(request.ticketSpec);
1227
+ const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan/nodes/` +
1228
+ `${encodeURIComponent(request.ticketKey)}/ticket-spec`);
1229
+ const body = JSON.stringify({
1230
+ repo_name: access.repoName,
1231
+ expected_plan_hash: request.expectedPlanHash,
1232
+ ticket_spec: request.ticketSpec,
1233
+ });
1234
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1235
+ return parsed;
1236
+ }
1022
1237
  /**
1023
1238
  * Atomically approve a plan version. Bumps `epic_runs.approved_plan_hash` to
1024
1239
  * the named version's `plan_hash` in a single CAS write — never a
@@ -1203,3 +1418,177 @@ export async function transitionJiraStatus(access, ticketNumber, targetStatus =
1203
1418
  throw err;
1204
1419
  }
1205
1420
  }
1421
+ const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
1422
+ const ACTIONS_LEVELS = new Set(["write", "read", "none", "unknown"]);
1423
+ function readinessShapeError() {
1424
+ // Deliberately carries NO field name, value, or payload excerpt: a malformed
1425
+ // body is untrusted input and its contents must not reach output.
1426
+ return new ConductorBridgeApiError("invalid-input", undefined, {
1427
+ errorCode: "READINESS_SHAPE_INVALID",
1428
+ });
1429
+ }
1430
+ function requireObject(value) {
1431
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1432
+ throw readinessShapeError();
1433
+ }
1434
+ return value;
1435
+ }
1436
+ function requireBool(o, key) {
1437
+ const v = o[key];
1438
+ if (typeof v !== "boolean")
1439
+ throw readinessShapeError();
1440
+ return v;
1441
+ }
1442
+ function requireInt(o, key) {
1443
+ const v = o[key];
1444
+ if (typeof v !== "number" || !Number.isInteger(v))
1445
+ throw readinessShapeError();
1446
+ return v;
1447
+ }
1448
+ function requireNullableInt(o, key) {
1449
+ const v = o[key];
1450
+ if (v === null || v === undefined)
1451
+ return null;
1452
+ if (typeof v !== "number" || !Number.isInteger(v))
1453
+ throw readinessShapeError();
1454
+ return v;
1455
+ }
1456
+ function requireNullableString(o, key) {
1457
+ const v = o[key];
1458
+ if (v === null || v === undefined)
1459
+ return null;
1460
+ if (typeof v !== "string")
1461
+ throw readinessShapeError();
1462
+ return v;
1463
+ }
1464
+ function requireNullableBool(o, key) {
1465
+ const v = o[key];
1466
+ if (v === null || v === undefined)
1467
+ return null;
1468
+ if (typeof v !== "boolean")
1469
+ throw readinessShapeError();
1470
+ return v;
1471
+ }
1472
+ function requireEnum(o, key, allowed) {
1473
+ const v = o[key];
1474
+ if (typeof v !== "string" || !allowed.has(v))
1475
+ throw readinessShapeError();
1476
+ return v;
1477
+ }
1478
+ /**
1479
+ * Validate a raw readiness body into the typed response, FAIL-CLOSED.
1480
+ *
1481
+ * Every field the composed doctor makes a safety decision on is checked for
1482
+ * presence and exact type. This is not defensive nicety: a 200 whose body is
1483
+ * missing `required_checks_empty`, or carries it as the string `"false"`, would
1484
+ * otherwise be cast straight into an apparently-healthy report and the operator
1485
+ * would be told a trivially-passing gate was fine.
1486
+ */
1487
+ export function parseConductorReadinessResponse(body) {
1488
+ const root = requireObject(body);
1489
+ const repoName = root.repo_name;
1490
+ if (typeof repoName !== "string" || repoName.length === 0)
1491
+ throw readinessShapeError();
1492
+ const sup = requireObject(root.supervisor);
1493
+ const gh = requireObject(root.github);
1494
+ const rec = requireObject(root.reconciler);
1495
+ const exec = requireObject(root.executor);
1496
+ const thr = requireObject(root.thresholds);
1497
+ return {
1498
+ repo_name: repoName,
1499
+ supervisor: {
1500
+ setup_present: requireBool(sup, "setup_present"),
1501
+ setup_source: requireEnum(sup, "setup_source", READINESS_SOURCES),
1502
+ setup_created_at: requireNullableString(sup, "setup_created_at"),
1503
+ setup_updated_at: requireNullableString(sup, "setup_updated_at"),
1504
+ config_present: requireBool(sup, "config_present"),
1505
+ config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
1506
+ config_created_at: requireNullableString(sup, "config_created_at"),
1507
+ config_updated_at: requireNullableString(sup, "config_updated_at"),
1508
+ required_checks_count: requireInt(sup, "required_checks_count"),
1509
+ required_checks_empty: requireBool(sup, "required_checks_empty"),
1510
+ auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
1511
+ merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
1512
+ },
1513
+ github: {
1514
+ credentials_readable: requireBool(gh, "credentials_readable"),
1515
+ owner_resolved: requireBool(gh, "owner_resolved"),
1516
+ repo_id_resolved: requireBool(gh, "repo_id_resolved"),
1517
+ installation_id_resolved: requireBool(gh, "installation_id_resolved"),
1518
+ credentials_complete: requireBool(gh, "credentials_complete"),
1519
+ actions_probe_succeeded: requireBool(gh, "actions_probe_succeeded"),
1520
+ actions_permission_present: requireBool(gh, "actions_permission_present"),
1521
+ actions_permission_level: requireEnum(gh, "actions_permission_level", ACTIONS_LEVELS),
1522
+ actions_write: requireBool(gh, "actions_write"),
1523
+ },
1524
+ reconciler: {
1525
+ liveness_readable: requireBool(rec, "liveness_readable"),
1526
+ last_tick_at: requireNullableString(rec, "last_tick_at"),
1527
+ last_tick_age_seconds: requireNullableInt(rec, "last_tick_age_seconds"),
1528
+ stale: requireBool(rec, "stale"),
1529
+ active_run_count: requireInt(rec, "active_run_count"),
1530
+ expired_lease_count: requireInt(rec, "expired_lease_count"),
1531
+ },
1532
+ executor: {
1533
+ liveness_readable: requireBool(exec, "liveness_readable"),
1534
+ last_seen_at: requireNullableString(exec, "last_seen_at"),
1535
+ last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
1536
+ ready: requireNullableBool(exec, "ready"),
1537
+ },
1538
+ thresholds: {
1539
+ reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
1540
+ executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
1541
+ },
1542
+ };
1543
+ }
1544
+ /**
1545
+ * GET `/jira/epic-runs/conductor-readiness?repo_name=<repo>`.
1546
+ *
1547
+ * The API key travels ONLY in `X-API-Key`; `repo_name` is URL-encoded into the
1548
+ * query string because the endpoint scopes on it. Fails closed on transport,
1549
+ * auth, and shape errors alike.
1550
+ */
1551
+ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fetch) {
1552
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/conductor-readiness`, {
1553
+ repo_name: access.repoName,
1554
+ });
1555
+ const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1556
+ return parseConductorReadinessResponse(body);
1557
+ }
1558
+ /** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
1559
+ export function parseConductorSupervisorBootstrapResponse(body) {
1560
+ const o = requireObject(body);
1561
+ const repoName = o.repo_name;
1562
+ if (typeof repoName !== "string" || repoName.length === 0)
1563
+ throw readinessShapeError();
1564
+ const names = o.audited_field_names;
1565
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
1566
+ throw readinessShapeError();
1567
+ }
1568
+ return {
1569
+ repo_name: repoName,
1570
+ setup_written: requireBool(o, "setup_written"),
1571
+ config_written: requireBool(o, "config_written"),
1572
+ setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
1573
+ config_source: requireEnum(o, "config_source", READINESS_SOURCES),
1574
+ setup_created_at: requireNullableString(o, "setup_created_at"),
1575
+ setup_updated_at: requireNullableString(o, "setup_updated_at"),
1576
+ config_created_at: requireNullableString(o, "config_created_at"),
1577
+ config_updated_at: requireNullableString(o, "config_updated_at"),
1578
+ required_checks_count: requireInt(o, "required_checks_count"),
1579
+ audited_field_names: names,
1580
+ };
1581
+ }
1582
+ /**
1583
+ * POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
1584
+ *
1585
+ * The ONLY write this client performs. Deliberately carries no `review_policy`
1586
+ * field: repository-default review policy is owned by BAPI-694.
1587
+ */
1588
+ export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
1589
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
1590
+ repo_name: access.repoName,
1591
+ });
1592
+ const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1593
+ return parseConductorSupervisorBootstrapResponse(body);
1594
+ }
@@ -47,6 +47,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
47
47
  const { result, layer } = await runDenyEnforcementCheck(ctx, {
48
48
  model: opts.model,
49
49
  timeoutMs: opts.timeoutMs,
50
+ permissionPosture: opts.permissionPosture,
50
51
  });
51
52
  if (result.status === "pass" && layer === "settings-deny") {
52
53
  return {
@@ -83,15 +83,49 @@ function parseCiChecksCondition(entry) {
83
83
  }
84
84
  return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
85
85
  }
86
- /** Valid review source values. */
87
- const VALID_REVIEW_SOURCES = new Set(["sticky_verdict", "native_review_decision", "min_approvals", "combination"]);
86
+ /** Valid — i.e. CANONICAL — review source values (BAPI-689). */
87
+ const VALID_REVIEW_SOURCES = new Set(["verdict_protocol", "native_review_decision", "min_approvals", "combination"]);
88
+ /**
89
+ * Historical review-source spellings accepted on INPUT only.
90
+ *
91
+ * BAPI-689 unified three vocabularies onto one, but `done_gate_config` is
92
+ * free-form operator-authored JSONB that this module only ever reads — there is
93
+ * no migration that rewrites it. So already-stored configs keep naming the
94
+ * sticky-verdict source `sticky_verdict` (this module's own former spelling) or
95
+ * `claude_review_sticky` / `github_review_decision` (the former supervisor
96
+ * spellings), and they must stay readable.
97
+ *
98
+ * Mapping is applied at the parser seam and the CANONICAL value is what the
99
+ * parsed condition carries, so no alias survives into the config hash, the gate
100
+ * evaluator, or any serialized output.
101
+ */
102
+ const REVIEW_SOURCE_ALIASES = Object.freeze({
103
+ sticky_verdict: "verdict_protocol",
104
+ claude_review_sticky: "verdict_protocol",
105
+ github_review_decision: "native_review_decision",
106
+ });
107
+ /**
108
+ * Canonicalize a raw stored review-source value.
109
+ *
110
+ * Returns the canonical spelling for a known alias, the value unchanged when it
111
+ * is already canonical, and the value unchanged when it is neither — so an
112
+ * unsupported spelling still reaches (and fails) `VALID_REVIEW_SOURCES` rather
113
+ * than being coerced into a default. Exact-match only: no case folding and no
114
+ * trimming, matching the strictness of every other field in this parser.
115
+ */
116
+ function normalizeReviewSource(source) {
117
+ return REVIEW_SOURCE_ALIASES[source] ?? source;
118
+ }
88
119
  /**
89
120
  * Parse and validate a single `review_state` condition entry.
90
121
  * Returns the normalized condition or `null` on any validation failure.
91
122
  */
92
123
  function parseReviewStateCondition(entry) {
93
- const source = entry.source;
94
- if (typeof source !== "string" || !VALID_REVIEW_SOURCES.has(source))
124
+ const rawSource = entry.source;
125
+ if (typeof rawSource !== "string")
126
+ return null;
127
+ const source = normalizeReviewSource(rawSource);
128
+ if (!VALID_REVIEW_SOURCES.has(source))
95
129
  return null;
96
130
  const condition = { type: REVIEW_STATE, source: source };
97
131
  if (entry.require_sticky_verdict !== undefined) {
@@ -425,7 +459,12 @@ export function evaluateReviewCondition(condition, snapshot) {
425
459
  return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
426
460
  }
427
461
  const source = condition.source;
428
- if (source === "sticky_verdict") {
462
+ // BAPI-689: the condition's `source` is always CANONICAL here — every path to a
463
+ // `ReviewStateCondition` goes through `parseReviewStateCondition`, which maps
464
+ // the historical spellings before validating. `snapshot.sticky_verdict` is a
465
+ // different thing entirely (the parsed value OF the sticky comment) and keeps
466
+ // its name.
467
+ if (source === "verdict_protocol") {
429
468
  if (snapshot.sticky_verdict === "approved")
430
469
  return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
431
470
  if (snapshot.sticky_verdict === "changes_requested")
@@ -171,6 +171,12 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
171
171
  deps.log(`[epic-reconcile] shadow-freshness error for ${ticketKey}: ${safeDiagnosticMessage(err, "freshness error")}; holding`);
172
172
  }
173
173
  if (verdict === "stale") {
174
+ // BAPI-678: this covers ordinary unproven freshness AND the backend's
175
+ // warn-only downgrade of a `failed` shadow lifecycle. Identical handling
176
+ // is intentional — either way the watermark is unproven, so hold before
177
+ // claiming a dispatch key. The backend clocks the hold and parks the
178
+ // ticket if its own stale deadline expires, so "hold" is bounded there
179
+ // rather than repeating forever here.
174
180
  deps.log(`[epic-reconcile] holding ${ticketKey}: shadow watermark stale`);
175
181
  continue;
176
182
  }