@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -56,16 +56,18 @@
56
56
  import { readdir, readFile } from "node:fs/promises";
57
57
  import path, { dirname, join } from "node:path";
58
58
  import { CONDUCTOR_DEFAULT_BASE_URL, ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
59
- import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, renderEpicLaunchCommand, shellQuote, } from "./epic-launch-profile.js";
59
+ import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, epicLaunchPolicyDigest, pinEpicLaunchPolicy, renderEpicLaunchCommand, resolveEpicLaunchSelection, shellQuote, } from "./epic-launch-profile.js";
60
60
  import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
61
61
  import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
62
- import { policyDeclaresNotifyWebhook, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
62
+ import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
63
+ import { policyDeclaresNotifyWebhook, effectivePolicyFacts, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
63
64
  import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
64
65
  import { discoverEpicPlan, } from "./plan-discovery.js";
65
66
  import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
66
67
  import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
67
68
  import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
68
69
  import { readPlaneManifest } from "./plane/manifest.js";
70
+ import { baseUrlNamesPlaneEndpoint, planeEndpointForTarget } from "./plane/endpoint.js";
69
71
  import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
70
72
  import { resolveMemberInstanceId } from "./plane/supervisor.js";
71
73
  import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
@@ -75,6 +77,7 @@ import { isTerminalEpicRunStatus } from "./epic-run-status.js";
75
77
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
76
78
  // the printed invocation interpolates it rather than repeating it.
77
79
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
80
+ import { PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_DOCTOR_COMMAND, } from "./printed-remedy-commands.js";
78
81
  /** The conductor paths. Derived types below come from this constant. */
79
82
  export const DRIVE_EPIC_CONDUCTORS = ["v2"];
80
83
  /**
@@ -120,8 +123,9 @@ export const V2_READINESS_REQUIREMENTS = [
120
123
  // The repository-scoped `reconciler` section ("is this repository's work
121
124
  // moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
122
125
  // too old to report `bring_up`.
123
- satisfied: (r) => r.bring_up !== null && r.bring_up !== undefined
124
- ? r.bring_up.heartbeat_readable && r.bring_up.worker_present
126
+ satisfied: (r) => r.bring_up
127
+ ? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
128
+ r.bring_up.single_transition_authority
125
129
  : r.reconciler.liveness_readable && !r.reconciler.stale,
126
130
  },
127
131
  {
@@ -146,7 +150,7 @@ export const V2_READINESS_REQUIREMENTS = [
146
150
  // set — the server simply never said (BAPI-1102 review).
147
151
  {
148
152
  id: "unattended_consent",
149
- describe: "repository consent to unattended conductor runs",
153
+ describe: "repository consent to unattended Epic Implementer runs",
150
154
  unattendedOnly: true,
151
155
  // No fixed `remedy`: this requirement's action always depends on WHY.
152
156
  remedyFor: (r) => r.unattended === null || r.unattended === undefined
@@ -160,7 +164,7 @@ export const V2_READINESS_REQUIREMENTS = [
160
164
  },
161
165
  {
162
166
  id: "unattended_notify_default",
163
- describe: "a verified notify webhook default for escalation",
167
+ describe: "an escalation channel (a verified notify webhook or the account email)",
164
168
  unattendedOnly: true,
165
169
  remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
166
170
  "to the account so escalation alerts can be emailed, or pass --attended. " +
@@ -172,13 +176,14 @@ export const V2_READINESS_REQUIREMENTS = [
172
176
  },
173
177
  {
174
178
  id: "repository_readiness",
175
- describe: "confirmed review and conductor-CI workflows on the default branch",
179
+ describe: "confirmed review and epic-implementer-ci workflows on the default branch",
176
180
  unattendedOnly: true,
177
- // S5 seam: `install-bridge conductor` is the NESTED install-time surface
178
- // (BAPI-679), a different command from the top-level `conductor` ->
179
- // `epic-implementer` rename in S4/BAPI-1080. It is intentionally outside
180
- // S4 and every remedy string below that names it is left unchanged.
181
- remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
181
+ // BAPI-1359 (Miss 38): the remedy names the SPECIFIC missing item, from the
182
+ // same shared formatter the readiness row and the setup-epic refusal use. The
183
+ // fixed install-command sentence that stood here could never clear a missing
184
+ // migration-guard job — no install step creates one — so it is gone from this
185
+ // refusal.
186
+ remedyFor: (r) => repositoryWorkflowRemedy(r),
182
187
  satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
183
188
  },
184
189
  // BAPI-1135 — merge-execution eligibility is deliberately NOT a requirement here.
@@ -238,6 +243,24 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
238
243
  * already encodes that (setup, then config, then credentials).
239
244
  */
240
245
  export function classifyV2Readiness(readiness, options = {}) {
246
+ for (const diagnostic of readiness.diagnostics ?? []) {
247
+ if (diagnostic.severity === null || diagnostic.applicability === null) {
248
+ return {
249
+ kind: "operator_blocked",
250
+ id: "diagnostic-metadata-unknown",
251
+ describe: "declared diagnostic severity and applicability",
252
+ remedy: "Upgrade or repair the readiness producer, then retry the selected target.",
253
+ };
254
+ }
255
+ if (diagnostic.applicability === "applicable" && diagnostic.severity === "mandatory" && diagnostic.verdict !== "passed") {
256
+ return {
257
+ kind: "operator_blocked",
258
+ id: "mandatory-diagnostic-unverified",
259
+ describe: `mandatory diagnostic ${diagnostic.id}`,
260
+ remedy: "Resolve the mandatory diagnostic at the selected Bridge API target and retry.",
261
+ };
262
+ }
263
+ }
241
264
  // BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
242
265
  // requirements INAPPLICABLE, not merely tolerated. They authorize unattended
243
266
  // operation and automatic merge behavior; an attended run asks for neither, so
@@ -253,7 +276,9 @@ export function classifyV2Readiness(readiness, options = {}) {
253
276
  // which reads the policy and refuses if it declares none. Nothing is weakened:
254
277
  // `setup-epic` gates before the create, and the server re-checks the predicate
255
278
  // at approval and probes the URL there.
256
- const deferred = options.hasPolicyFile
279
+ const deferred = options.approvedStoredPolicy ||
280
+ options.effectivePolicy?.escalationChannel === "configured webhook" ||
281
+ (options.effectivePolicy === undefined && options.hasPolicyFile)
257
282
  ? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
258
283
  : V2_READINESS_REQUIREMENTS;
259
284
  // `--into-base` is the second opt-out, and it is structural rather than
@@ -270,8 +295,28 @@ export function classifyV2Readiness(readiness, options = {}) {
270
295
  ? gating.filter((req) => req.unattendedOnly !== true)
271
296
  : gating;
272
297
  const unmet = applicable.filter((req) => !req.satisfied(readiness));
273
- if (unmet.length === 0)
298
+ if (unmet.length === 0) {
299
+ // Older deployments have no bring-up block; their existing reconciler
300
+ // liveness predicate remains the available evidence. For deployments that
301
+ // do report process facts, an unreadable owner never becomes a green launch.
302
+ if (readiness.bring_up && !readiness.bring_up.heartbeat_readable) {
303
+ return {
304
+ kind: "operator_blocked",
305
+ id: "process-owner-unverified",
306
+ describe: "a readable reconciler process-owner heartbeat",
307
+ remedy: "Restore the process heartbeat source before launch.",
308
+ };
309
+ }
310
+ if (readiness.bring_up?.worker_present && !readiness.bring_up.single_transition_authority) {
311
+ return {
312
+ kind: "operator_blocked",
313
+ id: "process-owner-conflict",
314
+ describe: "one verified reconciler process owner",
315
+ remedy: "Resolve the missing or duplicate reconciler process before launch.",
316
+ };
317
+ }
274
318
  return { kind: "green" };
319
+ }
275
320
  const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
276
321
  if (blocker) {
277
322
  return {
@@ -307,10 +352,11 @@ export function getDriveEpicUsage() {
307
352
  "UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
308
353
  "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
309
354
  "repository base branch stays human-gated. Pass --attended to opt out. An",
310
- "unattended run has two repository prerequisites (consent, which is ON by",
311
- "default and switched on the Bridge admin panel's Setup page, and a verified",
312
- "notify webhook default) that readiness lists with their state and that are",
313
- "refused BY NAME before any run is created.",
355
+ "unattended run has three repository prerequisites (consent, which is ON by",
356
+ "default and switched on the Bridge admin panel's Setup page; an escalation",
357
+ "channel, either a verified notify webhook or the account's email address; and",
358
+ "confirmed review and CI workflows on the default branch) that readiness lists",
359
+ "with their state and that are refused BY NAME before any run is created.",
314
360
  "",
315
361
  "Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
316
362
  "created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
@@ -406,6 +452,10 @@ export function getDriveEpicUsage() {
406
452
  " non-production server and for durable hosts.",
407
453
  " --base-url <url> Explicit Bridge API target. With a profile it must",
408
454
  " agree with it — an override never rebinds a profile.",
455
+ "BAPI_EPIC_LAUNCH_PROFILE selects a host-local launch profile for epic",
456
+ "launches. --launch-profile takes precedence. For an environment-selected",
457
+ "profile, BAPI_BASE_URL remains the general CLI endpoint and does not",
458
+ "conflict with the epic launch target; --base-url still must agree.",
409
459
  "Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
410
460
  `targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
411
461
  "stored bapi:<repo> key when one is available. A stored key approved for a",
@@ -814,7 +864,7 @@ export function assertSingleConductorInvocation(text) {
814
864
  // commands (say, one per profile or target) would hand the reader a choice of
815
865
  // authority just as surely as naming two conductors. The same command
816
866
  // repeated is still one invocation.
817
- const runnable = new Set((text.match(/\bdrive-epic\s+[A-Z][A-Z0-9]*-[0-9]+[^\n]*/g) ?? []).map((line) => line.trim()));
867
+ const runnable = new Set((text.match(/\bdrive-epic\s+[A-Z][A-Z0-9_]{0,19}-[0-9]+[^\n]*/g) ?? []).map((line) => line.trim()));
818
868
  if (runnable.size > 1) {
819
869
  throw new Error(`drive-epic emitted ${runnable.size} different runnable launch commands. ` +
820
870
  "Exactly one path may ever be presented.");
@@ -879,19 +929,32 @@ export function parseContainmentEligibilityArtifact(raw) {
879
929
  return null;
880
930
  return { containmentChildren: containmentChildren, flagged };
881
931
  }
932
+ /** Sidecar name `assess-epic-implementer-eligibility` writes. */
933
+ export const ELIGIBILITY_ARTIFACT_NAME = "epic-implementer-eligibility.json";
934
+ /** Pre-rename sidecar name, read only when the current name is absent. */
935
+ export const LEGACY_ELIGIBILITY_ARTIFACT_NAME = "conductor-eligibility.json";
882
936
  /**
883
937
  * Locate and read the containment-eligibility sidecar for a supplied plan
884
- * file, then parse it. `conductor-eligibility.json` is always a sibling of
885
- * the plan file — `dirname(planFile)/conductor-eligibility.json` — the same
886
- * directory `assess-conductor-eligibility` writes it to. Fails open on every
938
+ * file, then parse it. The sidecar is always a sibling of the plan file —
939
+ * `dirname(planFile)/epic-implementer-eligibility.json` — the same directory
940
+ * `assess-epic-implementer-eligibility` writes it to. When that read fails,
941
+ * the legacy `conductor-eligibility.json` name is tried, so plans written
942
+ * before the rename still surface their advisory. Fails open on every
887
943
  * boundary: a resolution failure, a rejected read (missing file, permission
888
944
  * error), invalid JSON, or a malformed/inconsistent artifact all resolve to
889
945
  * `null`, never a thrown error.
890
946
  */
891
947
  export async function readContainmentEligibilityAdvisory(planFile, readEligibilityArtifact) {
892
948
  try {
893
- const artifactPath = join(dirname(planFile), "conductor-eligibility.json");
894
- const raw = await readEligibilityArtifact(artifactPath);
949
+ const planDir = dirname(planFile);
950
+ let raw;
951
+ try {
952
+ raw = await readEligibilityArtifact(join(planDir, ELIGIBILITY_ARTIFACT_NAME));
953
+ }
954
+ catch {
955
+ // Plans written before the rename carry only the legacy sidecar name.
956
+ raw = await readEligibilityArtifact(join(planDir, LEGACY_ELIGIBILITY_ARTIFACT_NAME));
957
+ }
895
958
  const parsed = JSON.parse(raw);
896
959
  return parseContainmentEligibilityArtifact(parsed);
897
960
  }
@@ -961,6 +1024,10 @@ export function firstPreconditionRefusal(preflight, options = {}) {
961
1024
  // ---------------------------------------------------------------------------
962
1025
  // BAPI-1153 — cold start, attach, or one named refusal
963
1026
  // ---------------------------------------------------------------------------
1027
+ // BAPI-1360: the predicate moved to the `plane/endpoint.ts` leaf so the readiness
1028
+ // command can share it; it is re-exported here so every existing import and launch
1029
+ // decision is unchanged.
1030
+ export { baseUrlNamesPlaneEndpoint };
964
1031
  /**
965
1032
  * The refusals drive-epic itself names on the cold-start and attach paths.
966
1033
  *
@@ -975,31 +1042,6 @@ export const DRIVE_EPIC_PLANE_REFUSALS = [
975
1042
  /** The plane's own reconciler is not confirmed as the fresh one. */
976
1043
  "plane-reconciler-unconfirmed",
977
1044
  ];
978
- /** Loopback spellings that name the plane's `127.0.0.1` host. */
979
- const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
980
- /**
981
- * Does `baseUrl` name the plane's own loopback endpoint? Pure.
982
- *
983
- * Cold start and attach are decisions about THE PLANE, so they only make sense
984
- * when the Bridge API the command talks to IS the plane's server. The conductor
985
- * default base URL is production; a readiness failure against it says nothing
986
- * about a local port, and must keep escalating exactly as before. Loopback
987
- * spellings are equivalent (`localhost` and `127.0.0.1` reach the same socket);
988
- * the port must match exactly.
989
- */
990
- export function baseUrlNamesPlaneEndpoint(baseUrl, endpoint) {
991
- let url;
992
- try {
993
- url = new URL(baseUrl);
994
- }
995
- catch {
996
- return false;
997
- }
998
- if (url.protocol !== "http:")
999
- return false;
1000
- const port = url.port === "" ? 80 : Number(url.port);
1001
- return LOOPBACK_HOSTNAMES.has(url.hostname) && port === endpoint.port;
1002
- }
1003
1045
  /**
1004
1046
  * Rebuild the identity the plane's reconciler publishes its heartbeat under. Pure.
1005
1047
  *
@@ -1077,7 +1119,14 @@ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalTh
1077
1119
  typeof run.status !== "string") {
1078
1120
  return { kind: "unknown", reason: "the run state response was malformed" };
1079
1121
  }
1080
- return { kind: "live", epicRunId: run.epic_run_id, status: run.status };
1122
+ return {
1123
+ kind: "live",
1124
+ epicRunId: run.epic_run_id,
1125
+ status: run.status,
1126
+ ...(run.policy_json !== undefined
1127
+ ? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored") }
1128
+ : {}),
1129
+ };
1081
1130
  }
1082
1131
  catch (err) {
1083
1132
  if (err instanceof ConductorBridgeApiError) {
@@ -1104,8 +1153,8 @@ export function createDefaultDriveEpicDeps() {
1104
1153
  ...(cwd ? { cwd } : {}),
1105
1154
  });
1106
1155
  return result.ok
1107
- ? { ok: true, access: result.access, profile: result.profile }
1108
- : { ok: false, error: result.error };
1156
+ ? { ok: true, access: result.access, profile: result.profile, targetSource: result.targetSource }
1157
+ : { ok: false, error: result.error, kind: result.kind, ...(result.baseUrl ? { baseUrl: result.baseUrl } : {}) };
1109
1158
  },
1110
1159
  // Non-secret, and only ever a value the resolver just agreed with the
1111
1160
  // environment on (a conflicting BAPI_BASE_URL was refused above), so the
@@ -1114,7 +1163,7 @@ export function createDefaultDriveEpicDeps() {
1114
1163
  process.env.BAPI_BASE_URL = baseUrl;
1115
1164
  },
1116
1165
  readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
1117
- runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
1166
+ runSetupEpic: (argv, handoff) => runSetupEpicWorkflow(argv, {}, handoff),
1118
1167
  readEligibilityArtifact: (path) => readFile(path, "utf8"),
1119
1168
  runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
1120
1169
  runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
@@ -1167,7 +1216,7 @@ export function createDefaultDriveEpicDeps() {
1167
1216
  readPlanDag: (filePath) => readFile(filePath, "utf8"),
1168
1217
  readPolicyFile: (filePath) => readFile(filePath, "utf8"),
1169
1218
  readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
1170
- previewSetupEpic: async ({ argv, baseUrl }) => {
1219
+ previewSetupEpic: async ({ argv, baseUrl, handoff }) => {
1171
1220
  const stdout = [];
1172
1221
  const stderr = [];
1173
1222
  const result = await runSetupEpicWorkflow(argv, {
@@ -1176,7 +1225,7 @@ export function createDefaultDriveEpicDeps() {
1176
1225
  env: { ...process.env, BAPI_BASE_URL: baseUrl },
1177
1226
  log: (message) => stdout.push(message),
1178
1227
  errorLog: (message) => stderr.push(message),
1179
- });
1228
+ }, handoff);
1180
1229
  return { exitCode: result.exitCode, stdout, stderr };
1181
1230
  },
1182
1231
  // Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
@@ -1251,6 +1300,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1251
1300
  options: parsed.options,
1252
1301
  access: accessResult.access,
1253
1302
  profile: launchProfile,
1303
+ targetSource: accessResult.targetSource ?? "default",
1254
1304
  };
1255
1305
  // BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
1256
1306
  // discovered plan must count as a supplied one for the BAPI-1153 cold-start
@@ -1319,6 +1369,16 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1319
1369
  }
1320
1370
  return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
1321
1371
  }
1372
+ if (ctx.options.planFile !== undefined) {
1373
+ const prepared = await prepareSetupSelection(ctx);
1374
+ if (!prepared.ok) {
1375
+ return refuse(deps, [
1376
+ `${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
1377
+ "Nothing was created or started.",
1378
+ ]);
1379
+ }
1380
+ ctx = { ...ctx, handoff: prepared.handoff };
1381
+ }
1322
1382
  // --- Route. Exactly one path from here on. -------------------------------
1323
1383
  const selection = selectConductor(readiness);
1324
1384
  // BAPI-1022: surface any containment-hazard advisory before dispatch or
@@ -1377,7 +1437,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1377
1437
  const classification = classifyV2Readiness(readiness, {
1378
1438
  attended: parsed.options.attended === true,
1379
1439
  intoBase: parsed.options.intoBase === true,
1380
- hasPolicyFile: parsed.options.policyFile !== undefined,
1440
+ effectivePolicy: ctx.handoff?.selection,
1381
1441
  });
1382
1442
  // Green: create the run, then make sure something will claim its jobs
1383
1443
  // (BAPI-1279). Before this, green delegated to setup-epic and nothing
@@ -1574,7 +1634,7 @@ const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
1574
1634
  */
1575
1635
  function operatorBlockedRemedy(classification) {
1576
1636
  return (classification.remedy ??
1577
- "Resolve it with `install-bridge conductor`, then run drive-epic again.");
1637
+ `Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again.`);
1578
1638
  }
1579
1639
  /**
1580
1640
  * The escalation branch. Prints NO conductor invocation at all — naming a
@@ -1594,6 +1654,61 @@ function escalate(deps, epicKey, reason) {
1594
1654
  deps.errorLog(text);
1595
1655
  return 1;
1596
1656
  }
1657
+ /** Resolve policy intent once for the setup delegation, after a fresh run read. */
1658
+ async function prepareSetupSelection(ctx, coldStart = false) {
1659
+ const live = coldStart ? { kind: "none" } : await ctx.deps.readLiveEpicRun(ctx.access, ctx.epicKey);
1660
+ if (live.kind === "conflict")
1661
+ return { ok: false, reasonId: "live-run-ambiguous" };
1662
+ if (live.kind === "unknown")
1663
+ return { ok: false, reasonId: "live-run-unknown" };
1664
+ const explicit = ctx.options.policyFile;
1665
+ const selectedPath = explicit ?? (live.kind === "none" ? ctx.profile?.policyRef?.path : undefined);
1666
+ let pinnedPolicy;
1667
+ let digest = null;
1668
+ if (selectedPath !== undefined) {
1669
+ let raw = "";
1670
+ const read = await readSetupEpicPolicyFile(selectedPath, async (filePath) => {
1671
+ raw = await ctx.deps.readPolicyFile(filePath);
1672
+ return raw;
1673
+ });
1674
+ if (!read.ok)
1675
+ return { ok: false, reasonId: "policy-file-unreadable" };
1676
+ pinnedPolicy = read.policy;
1677
+ digest = epicLaunchPolicyDigest(raw);
1678
+ }
1679
+ const policy = coldStart
1680
+ ? { kind: "deferred_cold_preview", source: "deferred", digest: null }
1681
+ : live.kind === "live"
1682
+ ? explicit && digest
1683
+ ? { kind: "explicit_replacement_request", source: "explicit", digest }
1684
+ : { kind: "approved_stored", source: "stored", digest: null }
1685
+ : { kind: "new_run", source: explicit ? "explicit" : selectedPath ? "profile" : "default", digest };
1686
+ const selection = resolveEpicLaunchSelection({
1687
+ baseUrl: ctx.access.baseUrl,
1688
+ repoName: ctx.access.repoName,
1689
+ targetSource: ctx.targetSource,
1690
+ profile: ctx.profile,
1691
+ topology: planeEndpointForBaseUrl(ctx.deps, ctx.access.baseUrl) ? "local_plane" : "remote",
1692
+ policy,
1693
+ escalationChannel: pinnedPolicy
1694
+ ? policyDeclaresNotifyWebhook(pinnedPolicy) ? "configured webhook" : "account email"
1695
+ : live.kind === "live" ? "unavailable" : "account email",
1696
+ });
1697
+ if (pinnedPolicy)
1698
+ pinEpicLaunchPolicy(selection, pinnedPolicy);
1699
+ return { ok: true, handoff: { selection, access: ctx.access, profile: ctx.profile } };
1700
+ }
1701
+ async function runSelectedSetupEpic(ctx) {
1702
+ const prepared = ctx.handoff
1703
+ ? { ok: true, handoff: ctx.handoff }
1704
+ : await prepareSetupSelection(ctx);
1705
+ if (!prepared.ok) {
1706
+ ctx.deps.errorLog(`${ctx.epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`);
1707
+ return { exitCode: 1 };
1708
+ }
1709
+ const result = await ctx.deps.runSetupEpic(buildSetupEpicArgv(ctx.options), prepared.handoff);
1710
+ return result;
1711
+ }
1597
1712
  /** Emit one refusal, guarded so it can never name a second conductor. */
1598
1713
  function refuse(deps, lines) {
1599
1714
  const text = lines.join("\n");
@@ -1601,6 +1716,11 @@ function refuse(deps, lines) {
1601
1716
  deps.errorLog(text);
1602
1717
  return 1;
1603
1718
  }
1719
+ function durableActorAdmissionDenied(readiness) {
1720
+ const evidence = readiness.admission_evidence;
1721
+ return evidence?.schema_version === 1 &&
1722
+ evidence.records.some((record) => record.predicate === "durable_actor" && record.verdict === "denied");
1723
+ }
1604
1724
  /**
1605
1725
  * The plane endpoint, when `baseUrl` names it; otherwise `null`.
1606
1726
  *
@@ -1609,10 +1729,7 @@ function refuse(deps, lines) {
1609
1729
  * applies.
1610
1730
  */
1611
1731
  function planeEndpointForBaseUrl(deps, baseUrl) {
1612
- const resolution = deps.resolvePlaneEndpoint();
1613
- if (!resolution.ok)
1614
- return null;
1615
- return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? resolution.endpoint : null;
1732
+ return planeEndpointForTarget(deps.resolvePlaneEndpoint(), baseUrl);
1616
1733
  }
1617
1734
  /** What to tell the operator about a plane THIS invocation started and left up. */
1618
1735
  const LAUNCHED_PLANE_LEFT_RUNNING = [
@@ -1706,10 +1823,18 @@ async function coldStartPlane(ctx) {
1706
1823
  ...LAUNCHED_PLANE_LEFT_RUNNING,
1707
1824
  ]);
1708
1825
  }
1826
+ const prepared = await prepareSetupSelection(ctx);
1827
+ if (!prepared.ok) {
1828
+ return refuse(deps, [
1829
+ `${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
1830
+ ...LAUNCHED_PLANE_LEFT_RUNNING,
1831
+ ]);
1832
+ }
1833
+ ctx = { ...ctx, handoff: prepared.handoff };
1709
1834
  const classification = classifyV2Readiness(readiness, {
1710
1835
  attended: ctx.options.attended === true,
1711
1836
  intoBase: ctx.options.intoBase === true,
1712
- hasPolicyFile: ctx.options.policyFile !== undefined,
1837
+ effectivePolicy: ctx.handoff?.selection,
1713
1838
  });
1714
1839
  if (classification.kind === "operator_blocked") {
1715
1840
  return refuse(deps, [
@@ -1755,6 +1880,12 @@ async function attachToPlane(ctx, manifest, readiness) {
1755
1880
  "Nothing was started. Fix the credential and run drive-epic again.",
1756
1881
  ]);
1757
1882
  }
1883
+ if (rooted.access.repoName !== ctx.access.repoName || rooted.access.baseUrl !== ctx.access.baseUrl) {
1884
+ return refuse(deps, [
1885
+ `${epicKey}: REFUSED (selection-conflict) — manifest-root credentials resolved a different repository or endpoint.`,
1886
+ "Nothing was started or changed.",
1887
+ ]);
1888
+ }
1758
1889
  const attachCtx = { ...ctx, access: rooted.access };
1759
1890
  // R-7, decided BEFORE any create request. It binds the LANES only
1760
1891
  // (BAPI-1279): a plane whose lanes serve another run still has a reconciler
@@ -1816,7 +1947,7 @@ async function createRunAndRequestLanes(ctx, plane) {
1816
1947
  const { deps, epicKey } = ctx;
1817
1948
  const launched = plane.origin === "launched";
1818
1949
  // ---- PHASE TWO, part one: create the run ------------------------------
1819
- const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
1950
+ const setup = await runSelectedSetupEpic(ctx);
1820
1951
  if (setup.exitCode !== 0) {
1821
1952
  // The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
1822
1953
  // was spawned — there is nothing to claim a job that may not exist.
@@ -2107,11 +2238,17 @@ function liveRunUnknownRefusal(epicKey, reason) {
2107
2238
  async function createRunWithRunExecutor(ctx, readiness) {
2108
2239
  const { deps, epicKey, access, profile } = ctx;
2109
2240
  if (profile !== null && profile.executionMode === "durable") {
2241
+ if (durableActorAdmissionDenied(readiness)) {
2242
+ return refuse(deps, [
2243
+ `${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
2244
+ "No run was created and no local executor was started.",
2245
+ ]);
2246
+ }
2110
2247
  const coverage = readiness.executor.repo_wide_executor_live;
2111
2248
  if (coverage !== true) {
2112
2249
  return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
2113
2250
  }
2114
- const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
2251
+ const setup = await runSelectedSetupEpic(ctx);
2115
2252
  if (setup.exitCode === 0) {
2116
2253
  deps.log([
2117
2254
  "",
@@ -2126,7 +2263,7 @@ async function createRunWithRunExecutor(ctx, readiness) {
2126
2263
  if (!prepared.ok) {
2127
2264
  return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
2128
2265
  }
2129
- const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
2266
+ const setup = await runSelectedSetupEpic(ctx);
2130
2267
  if (setup.exitCode !== 0) {
2131
2268
  // setup-epic already said why. Nothing was started for a run that may not exist.
2132
2269
  deps.errorLog(`\n${epicKey}: no executor was started.`);
@@ -2164,6 +2301,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
2164
2301
  function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
2165
2302
  const { deps, epicKey, access } = ctx;
2166
2303
  if (!ensured.ok) {
2304
+ deps.log(JSON.stringify({
2305
+ outcome: "run_exists_executor_unavailable",
2306
+ epic_key: epicKey,
2307
+ epic_run_id: epicRunId,
2308
+ }));
2167
2309
  return refuse(deps, [
2168
2310
  "",
2169
2311
  framing === "created"
@@ -2213,6 +2355,9 @@ async function planeServesLiveRun(ctx, live) {
2213
2355
  const manifest = await findAttachablePlane(ctx);
2214
2356
  if (manifest === null || manifest.epicRunId !== live.epicRunId)
2215
2357
  return false;
2358
+ const owner = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
2359
+ if (owner !== "fresh")
2360
+ return false;
2216
2361
  const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
2217
2362
  return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
2218
2363
  }
@@ -2236,7 +2381,7 @@ async function attachToLiveRun(ctx, readiness, live) {
2236
2381
  const classification = classifyV2Readiness(readiness, {
2237
2382
  attended: ctx.options.attended === true,
2238
2383
  intoBase: ctx.options.intoBase === true,
2239
- hasPolicyFile: ctx.options.policyFile !== undefined,
2384
+ approvedStoredPolicy: true,
2240
2385
  });
2241
2386
  if (classification.kind === "operator_blocked") {
2242
2387
  return refuse(deps, [
@@ -2252,9 +2397,28 @@ async function attachToLiveRun(ctx, readiness, live) {
2252
2397
  return refuseRemoteRuntime(ctx, readiness);
2253
2398
  return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
2254
2399
  }
2400
+ if (planeTarget) {
2401
+ const manifest = await findAttachablePlane(ctx).catch(() => null);
2402
+ if (manifest?.epicRunId === live.epicRunId) {
2403
+ const admission = await checkPlaneAdmission(ctx, readiness, manifest);
2404
+ if (admission !== null)
2405
+ return refuse(deps, admission);
2406
+ }
2407
+ }
2255
2408
  deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
2256
2409
  "found, so nothing will be created; making sure it has an executor.");
2410
+ if (live.effectivePolicy) {
2411
+ deps.log(`Effective policy: stored; serial execution; caps ${live.effectivePolicy.caps ? JSON.stringify(live.effectivePolicy.caps) : "unavailable"}; ` +
2412
+ `timeouts ${live.effectivePolicy.job_timeouts ? JSON.stringify(live.effectivePolicy.job_timeouts) : "unavailable"}; ` +
2413
+ `channel ${live.effectivePolicy.escalation_channel}.`);
2414
+ }
2257
2415
  if (profile !== null && profile.executionMode === "durable") {
2416
+ if (durableActorAdmissionDenied(readiness)) {
2417
+ return refuse(deps, [
2418
+ `${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
2419
+ "No run was changed and no local executor was started.",
2420
+ ]);
2421
+ }
2258
2422
  const coverage = readiness.executor.repo_wide_executor_live;
2259
2423
  if (coverage !== true) {
2260
2424
  return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
@@ -2339,9 +2503,19 @@ function reportControlPlaneLaunchFailure(ctx, controlPlane) {
2339
2503
  "No run was created.",
2340
2504
  ]);
2341
2505
  }
2506
+ export const DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS = {
2507
+ "profile-unusable": "access-profile-unusable",
2508
+ "target-missing": "access-target-missing",
2509
+ "target-conflict": "access-target-conflict",
2510
+ "target-invalid": "access-target-invalid",
2511
+ "repository-conflict": "access-repository-conflict",
2512
+ "repo-missing": "access-repo-missing",
2513
+ "credentials-unavailable": "access-credentials-unavailable",
2514
+ "credential-unbound": "access-credential-unbound",
2515
+ };
2342
2516
  /** The readiness reason a preview reports for a generic not-ready engine. */
2343
2517
  const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
2344
- const ENGINE_NOT_READY_REMEDY = "Resolve conductor readiness (`install-bridge conductor`, then `doctor`) and run drive-epic again.";
2518
+ const ENGINE_NOT_READY_REMEDY = `Resolve conductor readiness (\`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\`, then \`${PRINTED_DOCTOR_COMMAND}\`) and run drive-epic again. ${PRINTED_CHECK_NAME_INSTRUCTION}`;
2345
2519
  /**
2346
2520
  * Read exactly one well-shaped setup-epic dry-run JSON document out of captured
2347
2521
  * `log` output. Anything else — none, several, or one with the wrong shape — is
@@ -2370,12 +2544,48 @@ function readSetupPreviewPayload(stdout) {
2370
2544
  if (existing !== null && existing !== undefined && typeof existing !== "string")
2371
2545
  return null;
2372
2546
  const branch = doc.feature_branch;
2373
- if (branch !== undefined && typeof branch !== "string")
2547
+ if (branch !== undefined && branch !== null && typeof branch !== "string")
2374
2548
  return null;
2549
+ const rawFacts = doc.effective_policy;
2550
+ let effectivePolicy = null;
2551
+ if (rawFacts !== undefined && rawFacts !== null) {
2552
+ if (typeof rawFacts !== "object" || Array.isArray(rawFacts))
2553
+ return null;
2554
+ const facts = rawFacts;
2555
+ if (!["explicit", "profile", "default", "stored"].includes(String(facts.source)) ||
2556
+ facts.serial_execution !== "serial" ||
2557
+ !["attended", "unattended"].includes(String(facts.posture)) ||
2558
+ !["configured webhook", "account email", "unavailable"].includes(String(facts.escalation_channel)))
2559
+ return null;
2560
+ const numericMap = (value) => {
2561
+ if (value === null)
2562
+ return null;
2563
+ if (!value || typeof value !== "object" || Array.isArray(value))
2564
+ return undefined;
2565
+ const entries = Object.entries(value);
2566
+ if (entries.some(([key, number]) => !/^[a-z][a-z0-9_]{0,63}$/.test(key) ||
2567
+ typeof number !== "number" || !Number.isFinite(number)))
2568
+ return undefined;
2569
+ return Object.fromEntries(entries);
2570
+ };
2571
+ const caps = numericMap(facts.caps);
2572
+ const jobTimeouts = numericMap(facts.job_timeouts);
2573
+ if (caps === undefined || jobTimeouts === undefined)
2574
+ return null;
2575
+ effectivePolicy = {
2576
+ source: facts.source,
2577
+ serial_execution: "serial",
2578
+ posture: facts.posture,
2579
+ caps,
2580
+ job_timeouts: jobTimeouts,
2581
+ escalation_channel: facts.escalation_channel,
2582
+ };
2583
+ }
2375
2584
  return {
2376
2585
  existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
2377
2586
  featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
2378
2587
  serverValidated: doc.server_validated,
2588
+ effectivePolicy,
2379
2589
  };
2380
2590
  }
2381
2591
  /** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
@@ -2416,11 +2626,16 @@ async function runDriveEpicPreview(deps, options) {
2416
2626
  decision: "refuse",
2417
2627
  refusal: null,
2418
2628
  };
2419
- /** A refusal carried by the readiness block (and mirrored where the ticket needs it). */
2420
- const refuseWithReadiness = (kind, named, alsoTopLevel = false) => {
2629
+ /**
2630
+ * A refusal carried by the readiness block AND mirrored into the top-level
2631
+ * `refusal` (BAPI-1360): every readiness refusal now has one primary explanation
2632
+ * a consumer reads from a single place. The nested readiness pair is unchanged.
2633
+ * `_alsoTopLevel` is retained so existing call sites keep their signature; it no
2634
+ * longer selects anything.
2635
+ */
2636
+ const refuseWithReadiness = (kind, named, _alsoTopLevel = false) => {
2421
2637
  preview.readiness = { kind, reason_id: named.reasonId, remedy: named.remedy };
2422
- if (alsoTopLevel)
2423
- preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
2638
+ preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
2424
2639
  preview.decision = "refuse";
2425
2640
  };
2426
2641
  /** A refusal that is not about readiness: only the top-level block carries it. */
@@ -2440,9 +2655,39 @@ async function runDriveEpicPreview(deps, options) {
2440
2655
  remedy: "The preview could not complete. Run it again; nothing was created or started.",
2441
2656
  });
2442
2657
  }
2658
+ ensureRefusalExplained(preview);
2443
2659
  emitPreview(deps, options, preview);
2444
2660
  return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
2445
2661
  }
2662
+ /** The fixed explanation used when a refusal reached the end with none of its own. */
2663
+ export const PREVIEW_REFUSAL_FALLBACK_REASON_ID = "preview-refused";
2664
+ const PREVIEW_REFUSAL_FALLBACK_REMEDY = "The preview refused this launch without naming a specific cause. Resolve conductor readiness and preview again; nothing was created or started.";
2665
+ function isNamedRefusal(value) {
2666
+ if (value === null || typeof value !== "object")
2667
+ return false;
2668
+ const { reason_id: reasonId, remedy } = value;
2669
+ return typeof reasonId === "string" && reasonId.trim() !== "" && typeof remedy === "string" && remedy.trim() !== "";
2670
+ }
2671
+ /**
2672
+ * Every `refuse` decision carries a primary explanation (BAPI-1360).
2673
+ *
2674
+ * Runs after `previewDecision`, whatever path it took. A still-absent or malformed
2675
+ * top-level `refusal` on a refusing preview is filled from the readiness pair when
2676
+ * that pair is valid, and otherwise from a fixed generic reason and remedy — never
2677
+ * from anything thrown or received. An already-valid refusal is left untouched.
2678
+ * The exit code does not depend on this: a refusing decision exits non-zero either
2679
+ * way, so a malformed field can never turn a refusal into a success.
2680
+ */
2681
+ export function ensureRefusalExplained(preview) {
2682
+ if (preview.decision !== "refuse")
2683
+ return;
2684
+ if (isNamedRefusal(preview.refusal))
2685
+ return;
2686
+ const { reason_id: reasonId, remedy } = preview.readiness;
2687
+ preview.refusal = isNamedRefusal({ reason_id: reasonId, remedy })
2688
+ ? { reason_id: reasonId, remedy: remedy }
2689
+ : { reason_id: PREVIEW_REFUSAL_FALLBACK_REASON_ID, remedy: PREVIEW_REFUSAL_FALLBACK_REMEDY };
2690
+ }
2446
2691
  /** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
2447
2692
  async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
2448
2693
  const { epicKey } = options;
@@ -2452,62 +2697,135 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2452
2697
  });
2453
2698
  // --- Target: resolved, never adopted. -------------------------------------
2454
2699
  const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
2455
- if (!accessResult.ok)
2456
- return unknownReadiness();
2700
+ if (!accessResult.ok) {
2701
+ if (!accessResult.kind ||
2702
+ !Object.prototype.hasOwnProperty.call(DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS, accessResult.kind))
2703
+ return unknownReadiness();
2704
+ unknownReadiness();
2705
+ if (accessResult.baseUrl) {
2706
+ preview.target = {
2707
+ base_url: accessResult.baseUrl,
2708
+ kind: planeEndpointForBaseUrl(deps, accessResult.baseUrl) !== null ? "local_plane" : "remote",
2709
+ };
2710
+ }
2711
+ refuseTopLevel({
2712
+ reasonId: DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS[accessResult.kind],
2713
+ remedy: accessResult.error,
2714
+ });
2715
+ return;
2716
+ }
2457
2717
  const access = accessResult.access;
2458
2718
  const profile = accessResult.profile ?? null;
2459
- const planeTarget = planeEndpointForBaseUrl(deps, access.baseUrl) !== null;
2460
- preview.target = { base_url: access.baseUrl, kind: planeTarget ? "local_plane" : "remote" };
2461
- const ctx = { deps, epicKey, options, access, profile };
2719
+ const endpoint = planeEndpointForBaseUrl(deps, access.baseUrl);
2720
+ const planeTarget = endpoint !== null;
2721
+ preview.target = {
2722
+ base_url: access.baseUrl,
2723
+ kind: planeTarget ? "local_plane" : "remote",
2724
+ ...(accessResult.targetSource === "profile" || accessResult.targetSource === "profile-env"
2725
+ ? { source: accessResult.targetSource }
2726
+ : {}),
2727
+ };
2728
+ const ctx = { deps, epicKey, options, access, profile, targetSource: accessResult.targetSource ?? "default" };
2729
+ // Discover before readiness so a selected local plane can preview its guarded
2730
+ // cold start. Keep the candidate out of ordinary failure documents.
2731
+ let planPath = options.planFile;
2732
+ let plan = { status: "not_found" };
2733
+ let docsRootUnresolved = false;
2734
+ if (planPath !== undefined) {
2735
+ plan = { status: "supplied", path: planPath };
2736
+ }
2737
+ else {
2738
+ const docs = await deps.resolveDocsDir();
2739
+ if (!docs.ok) {
2740
+ docsRootUnresolved = true;
2741
+ }
2742
+ else {
2743
+ const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
2744
+ plan = {
2745
+ status: discovered.status === "found" ? "found" : discovered.status === "plan_ambiguous" ? "ambiguous" : "not_found",
2746
+ ...(discovered.status === "found" ? { path: discovered.path } : {}),
2747
+ ...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
2748
+ ...(discovered.skipped.length > 0
2749
+ ? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
2750
+ : {}),
2751
+ epic_plans_dir: discovered.epic_plans_dir,
2752
+ };
2753
+ if (discovered.status === "found")
2754
+ planPath = discovered.path;
2755
+ }
2756
+ }
2462
2757
  // --- Readiness. -----------------------------------------------------------
2463
2758
  let readiness;
2464
2759
  try {
2465
2760
  readiness = await deps.readReadiness(access);
2466
2761
  }
2467
- catch {
2762
+ catch (err) {
2763
+ if (planPath !== undefined && endpoint !== null && profile?.executionMode !== "durable" &&
2764
+ err instanceof ConductorBridgeApiError && err.kind === "network") {
2765
+ const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
2766
+ if (probe.kind === "refused") {
2767
+ const preflight = await deps.runPlanePreflight(deps.cwd());
2768
+ const blocker = firstPreconditionRefusal(preflight) ??
2769
+ preflight.diagnostics.find((diagnostic) => diagnostic.severity === "blocking");
2770
+ preview.plan = plan;
2771
+ if (blocker) {
2772
+ return refuseTopLevel({
2773
+ reasonId: blocker.check,
2774
+ remedy: `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
2775
+ });
2776
+ }
2777
+ if (!preflight.ok) {
2778
+ return refuseTopLevel({
2779
+ reasonId: "plane-preflight-failed",
2780
+ remedy: "The local plane preflight could not admit guarded startup. Fix its reported condition and preview again.",
2781
+ });
2782
+ }
2783
+ preview.readiness = { kind: "cold_start" };
2784
+ await finalizeCreatePreview(ctx, preview, planPath, true, refuseTopLevel);
2785
+ if (preview.decision === "create") {
2786
+ preview.cold_start = { plane_endpoint: access.baseUrl, will_start_plane: true, live_run_checked: false };
2787
+ }
2788
+ return;
2789
+ }
2790
+ }
2468
2791
  return unknownReadiness();
2469
2792
  }
2793
+ preview.plan = plan;
2794
+ if (docsRootUnresolved)
2795
+ return refuseTopLevel(docsRootRefusal(epicKey));
2796
+ const live = await deps.readLiveEpicRun(access, epicKey);
2797
+ if (live.kind === "conflict")
2798
+ return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
2799
+ if (live.kind === "unknown")
2800
+ return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
2801
+ const previewCtx = { ...ctx, options: { ...options, ...(planPath ? { planFile: planPath } : {}) } };
2802
+ const prepared = planPath ? await prepareSetupSelection(previewCtx) : null;
2803
+ if (prepared !== null && !prepared.ok) {
2804
+ return refuseTopLevel({
2805
+ reasonId: prepared.reasonId,
2806
+ remedy: "The selected run or policy could not be verified. Nothing was created or started.",
2807
+ });
2808
+ }
2470
2809
  const classification = classifyV2Readiness(readiness, {
2471
2810
  attended: options.attended === true,
2472
2811
  intoBase: options.intoBase === true,
2473
- hasPolicyFile: options.policyFile !== undefined,
2812
+ effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
2813
+ approvedStoredPolicy: live.kind === "live" && !planPath,
2474
2814
  });
2475
2815
  preview.readiness = { kind: classification.kind };
2476
2816
  const selection = selectConductor(readiness);
2477
- // --- The plan: supplied, or discovered. -----------------------------------
2478
- let planPath = options.planFile;
2479
- if (planPath !== undefined) {
2480
- preview.plan = { status: "supplied", path: planPath };
2481
- }
2482
- else {
2483
- const docs = await deps.resolveDocsDir();
2484
- if (!docs.ok)
2485
- return refuseTopLevel(docsRootRefusal(epicKey));
2486
- const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
2487
- preview.plan = {
2488
- status: discovered.status === "found"
2489
- ? "found"
2490
- : discovered.status === "plan_ambiguous"
2491
- ? "ambiguous"
2492
- : "not_found",
2493
- ...(discovered.status === "found" ? { path: discovered.path } : {}),
2494
- ...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
2495
- ...(discovered.skipped.length > 0
2496
- ? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
2497
- : {}),
2498
- epic_plans_dir: discovered.epic_plans_dir,
2499
- };
2500
- if (discovered.status === "found")
2501
- planPath = discovered.path;
2502
- }
2503
2817
  // --- The live run. Conflict and unknown refuse whatever the plan says. ------
2504
- const live = await deps.readLiveEpicRun(access, epicKey);
2505
- if (live.kind === "conflict")
2506
- return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
2507
- if (live.kind === "unknown")
2508
- return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
2509
2818
  if (live.kind === "live") {
2510
2819
  preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
2820
+ if (live.effectivePolicy) {
2821
+ preview.summary.policy_source = live.effectivePolicy.source;
2822
+ preview.summary.serial_execution = live.effectivePolicy.serial_execution;
2823
+ preview.summary.caps = live.effectivePolicy.caps;
2824
+ preview.summary.job_timeouts = live.effectivePolicy.job_timeouts;
2825
+ preview.summary.escalation_channel_label = live.effectivePolicy.escalation_channel;
2826
+ preview.summary.posture = live.effectivePolicy.posture;
2827
+ preview.summary.escalation_channel = null;
2828
+ }
2511
2829
  }
2512
2830
  // --- No plan: attach to a live run, or stop. --------------------------------
2513
2831
  if (planPath === undefined) {
@@ -2537,31 +2855,32 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2537
2855
  const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
2538
2856
  if (facts !== null)
2539
2857
  return refuseWithReadiness(classification.kind, facts, true);
2858
+ await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPath, false, refuseTopLevel, live.kind === "live");
2859
+ }
2860
+ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTopLevel, liveRunKnown = false) {
2861
+ const { deps, options, access } = ctx;
2862
+ const prepared = ctx.handoff
2863
+ ? { ok: true, handoff: ctx.handoff }
2864
+ : await prepareSetupSelection(ctx, coldStart);
2865
+ if (!prepared.ok) {
2866
+ return refuseTopLevel({
2867
+ reasonId: prepared.reasonId,
2868
+ remedy: "The selected run or policy could not be verified. Fix it and preview again.",
2869
+ });
2870
+ }
2540
2871
  // Escalation channel, decided with setup-epic's own exported policy helpers. A
2541
2872
  // policy that cannot be read is one fixed refusal; its path, contents, and any
2542
2873
  // webhook URL never leave this function.
2543
2874
  if (preview.summary.posture === "unattended") {
2544
- if (options.policyFile !== undefined) {
2545
- const policy = await readSetupEpicPolicyFile(options.policyFile, deps.readPolicyFile);
2546
- if (!policy.ok) {
2547
- return refuseTopLevel({
2548
- reasonId: "policy-file-unreadable",
2549
- remedy: "The --policy-file could not be read as a JSON object. Fix it or pass a different " +
2550
- "file, then run the preview again.",
2551
- });
2552
- }
2553
- preview.summary.escalation_channel = policyDeclaresNotifyWebhook(policy.policy)
2554
- ? "webhook"
2555
- : "email";
2556
- }
2557
- else {
2558
- preview.summary.escalation_channel = "email";
2559
- }
2875
+ preview.summary.escalation_channel = prepared.handoff.selection.escalationChannel === "configured webhook"
2876
+ ? "webhook"
2877
+ : prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
2560
2878
  }
2561
2879
  preview.summary.child_count = await readPlanChildCount(deps, planPath);
2562
2880
  const setup = await deps.previewSetupEpic({
2563
- argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), "--dry-run", "--json"],
2881
+ argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
2564
2882
  baseUrl: access.baseUrl,
2883
+ handoff: prepared.handoff,
2565
2884
  });
2566
2885
  if (setup.exitCode !== 0) {
2567
2886
  preview.summary.server_validation = "failed";
@@ -2582,6 +2901,18 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2582
2901
  }
2583
2902
  preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
2584
2903
  preview.summary.branch = payload.featureBranch;
2904
+ if (payload.effectivePolicy) {
2905
+ const facts = payload.effectivePolicy;
2906
+ preview.summary.policy_source = facts.source;
2907
+ preview.summary.serial_execution = facts.serial_execution;
2908
+ preview.summary.caps = facts.caps;
2909
+ preview.summary.job_timeouts = facts.job_timeouts;
2910
+ preview.summary.escalation_channel_label = facts.escalation_channel;
2911
+ if (!coldStart)
2912
+ preview.summary.posture = facts.posture;
2913
+ preview.summary.escalation_channel = facts.escalation_channel === "configured webhook"
2914
+ ? "webhook" : facts.escalation_channel === "account email" ? "email" : null;
2915
+ }
2585
2916
  preview.summary.auto_merge_scope =
2586
2917
  preview.summary.posture === "unattended" ? payload.featureBranch : null;
2587
2918
  // The bootstrap reusing a run IS an attach: it reports the run's id.
@@ -2593,7 +2924,7 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2593
2924
  preview.decision = "attach";
2594
2925
  return;
2595
2926
  }
2596
- preview.decision = live.kind === "live" ? "attach" : "create";
2927
+ preview.decision = coldStart ? "create" : liveRunKnown ? "attach" : "create";
2597
2928
  }
2598
2929
  /**
2599
2930
  * The readiness refusal a real launch would print, as a reason id and remedy, or
@@ -2623,6 +2954,12 @@ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, pa
2623
2954
  async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
2624
2955
  const { deps, epicKey, access, profile } = ctx;
2625
2956
  if (profile !== null && profile.executionMode === "durable") {
2957
+ if (durableActorAdmissionDenied(readiness)) {
2958
+ return {
2959
+ reasonId: "actor-admission-denied",
2960
+ remedy: "Durable actor admission was denied at the selected target. No run was created and no local executor was started.",
2961
+ };
2962
+ }
2626
2963
  const coverage = readiness.executor.repo_wide_executor_live;
2627
2964
  return coverage === true
2628
2965
  ? null
@@ -2630,6 +2967,13 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
2630
2967
  }
2631
2968
  if (planeTarget) {
2632
2969
  if (live !== null) {
2970
+ const manifest = await findAttachablePlane(ctx).catch(() => null);
2971
+ if (manifest?.epicRunId === live.epicRunId) {
2972
+ const admission = await checkPlaneAdmission(ctx, readiness, manifest);
2973
+ if (admission !== null) {
2974
+ return { reasonId: "plane-reconciler-unconfirmed", remedy: admission.slice(1).join(" ") };
2975
+ }
2976
+ }
2633
2977
  if (await planeServesLiveRun(ctx, live))
2634
2978
  return null;
2635
2979
  }
@@ -2650,11 +2994,12 @@ function emitPreview(deps, options, preview) {
2650
2994
  }
2651
2995
  const lines = [
2652
2996
  `${preview.epic_key}: launch preview (read-only; nothing was created or started)`,
2997
+ ...(preview.cold_start ? [" Decision: conditional create — drive-epic will attempt guarded startup, recheck the live run, and create only if still appropriate."] : []),
2653
2998
  ` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
2654
2999
  ` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
2655
3000
  ` Live run: ${preview.live_run
2656
3001
  ? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
2657
- : "none"}`,
3002
+ : preview.cold_start ? "unchecked (plane not yet running)" : "none"}`,
2658
3003
  ` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
2659
3004
  ];
2660
3005
  for (const candidate of preview.plan.candidates ?? [])
@@ -2663,9 +3008,12 @@ function emitPreview(deps, options, preview) {
2663
3008
  lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
2664
3009
  }
2665
3010
  const s = preview.summary;
2666
- lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
2667
- if (preview.readiness.remedy)
3011
+ lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
3012
+ if (preview.readiness.remedy &&
3013
+ preview.readiness.remedy !== preview.refusal?.remedy &&
3014
+ !preview.refusal?.reason_id.startsWith("access-")) {
2668
3015
  lines.push(` Remedy: ${preview.readiness.remedy}`);
3016
+ }
2669
3017
  if (preview.refusal) {
2670
3018
  lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
2671
3019
  }