@bridge_gpt/mcp-server 0.2.60 → 0.2.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -53,11 +53,15 @@
53
53
  * ratcheted down, and a tool would be useless in a bare-chat session anyway,
54
54
  * where the model has no tool call to make.
55
55
  */
56
- import { readFile } from "node:fs/promises";
57
- import { dirname, join } from "node:path";
58
- import { ConductorBridgeApiError, resolveConductorBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
56
+ import { readdir, readFile } from "node:fs/promises";
57
+ import path, { dirname, join } from "node:path";
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";
60
+ import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
59
61
  import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
60
- import { runSetupEpicWorkflow, } from "./setup-epic.js";
62
+ import { policyDeclaresNotifyWebhook, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
63
+ import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
64
+ import { discoverEpicPlan, } from "./plan-discovery.js";
61
65
  import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
62
66
  import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
63
67
  import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
@@ -66,11 +70,11 @@ import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
66
70
  import { resolveMemberInstanceId } from "./plane/supervisor.js";
67
71
  import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
68
72
  import { validateBranchName } from "./base-ref.js";
73
+ import { createDefaultRunExecutorLaunchDeps, ensureRunExecutor as ensureRunExecutorDefault, prepareRunExecutor as prepareRunExecutorDefault, } from "./executor-launch.js";
74
+ import { isTerminalEpicRunStatus } from "./epic-run-status.js";
69
75
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
70
76
  // the printed invocation interpolates it rather than repeating it.
71
77
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
72
- /** Jira epic key shape. Matches the epic-key positional rule exactly. */
73
- const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
74
78
  /** The conductor paths. Derived types below come from this constant. */
75
79
  export const DRIVE_EPIC_CONDUCTORS = ["v2"];
76
80
  /**
@@ -110,11 +114,22 @@ export const V2_READINESS_REQUIREMENTS = [
110
114
  {
111
115
  id: "reconciler_live",
112
116
  describe: "a reconciler that is ticking and not stale",
113
- satisfied: (r) => r.reconciler.liveness_readable && !r.reconciler.stale,
117
+ // BAPI-1279 — launch liveness is a DEPLOYMENT fact. The `bring_up` block
118
+ // reads the reconciler's process heartbeat deployment-wide, so a repository
119
+ // that has never run an epic still reads live when the worker is healthy.
120
+ // The repository-scoped `reconciler` section ("is this repository's work
121
+ // moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
122
+ // 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
125
+ : r.reconciler.liveness_readable && !r.reconciler.stale,
114
126
  },
115
127
  {
116
128
  id: "executor_live",
117
129
  describe: "an executor provisioned and reporting ready",
130
+ // BAPI-1279 — diagnostic only. drive-epic starts (or, for a durable profile,
131
+ // verifies) the run's executor itself once the run exists.
132
+ advisoryOnly: true,
118
133
  satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
119
134
  },
120
135
  // BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
@@ -138,17 +153,22 @@ export const V2_READINESS_REQUIREMENTS = [
138
153
  ? "This Bridge API deploy does not report a repository's unattended " +
139
154
  "prerequisites at all, so none of them could be confirmed. Upgrade the " +
140
155
  "deploy, or pass --attended."
141
- : "Set `unattended_epic_implementer_allowed` for this repository " +
142
- "(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
156
+ : "Unattended runs are off for this repository. Turn on 'Allow unattended " +
157
+ "Epic Implementer runs' on the repository's Setup page in the Bridge " +
158
+ "admin panel, or pass --attended. If it is already on, contact Bridge support.",
143
159
  satisfied: (r) => r.unattended?.conductor_allowed === true,
144
160
  },
145
161
  {
146
162
  id: "unattended_notify_default",
147
163
  describe: "a verified notify webhook default for escalation",
148
164
  unattendedOnly: true,
149
- remedy: "Supply a --policy-file declaring notify.webhook_url, or pass --attended. " +
165
+ remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
166
+ "to the account so escalation alerts can be emailed, or pass --attended. " +
150
167
  "notify.local_sink does not satisfy this.",
151
- satisfied: (r) => r.unattended?.notify_webhook_default_declared === true,
168
+ // BAPI-1274 — the account's email address is an escalation channel when no
169
+ // webhook is declared; the server reports its presence, never the address.
170
+ satisfied: (r) => r.unattended?.notify_webhook_default_declared === true ||
171
+ r.unattended?.escalation_email_available === true,
152
172
  },
153
173
  {
154
174
  id: "repository_readiness",
@@ -188,7 +208,10 @@ export const V2_READINESS_REQUIREMENTS = [
188
208
  /**
189
209
  * The readiness facts a `plane up` can actually repair (BAPI-1054).
190
210
  *
191
- * Both are liveness of a process this machine starts. Everything else —
211
+ * BAPI-1279 narrowed this to the reconciler. `executor_live` is advisory now
212
+ * (see `advisoryOnly`): drive-epic ensures the run's executor itself after the
213
+ * run is created, so its absence before then is never unmet. What remains is
214
+ * liveness of a process this machine starts. Everything else —
192
215
  * `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
193
216
  * `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
194
217
  * stored server-side state, credentials, or repository configuration, and no
@@ -201,7 +224,7 @@ export const V2_READINESS_REQUIREMENTS = [
201
224
  * running plane behind and change nothing — which is the precise failure the
202
225
  * classification exists to prevent.
203
226
  */
204
- export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
227
+ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
205
228
  /**
206
229
  * Classify the SERVER's readiness report into the composition's three cases.
207
230
  *
@@ -241,9 +264,11 @@ export function classifyV2Readiness(readiness, options = {}) {
241
264
  // to say about — the same reasoning `--attended` rests on, reached by a
242
265
  // different route. See `composeDefaultSetupEpicPolicy`.
243
266
  const optedOut = options.attended === true || options.intoBase === true;
267
+ // BAPI-1279 — advisory rows are reported, never unmet.
268
+ const gating = deferred.filter((req) => req.advisoryOnly !== true);
244
269
  const applicable = optedOut
245
- ? deferred.filter((req) => req.unattendedOnly !== true)
246
- : deferred;
270
+ ? gating.filter((req) => req.unattendedOnly !== true)
271
+ : gating;
247
272
  const unmet = applicable.filter((req) => !req.satisfied(readiness));
248
273
  if (unmet.length === 0)
249
274
  return { kind: "green" };
@@ -282,18 +307,34 @@ export function getDriveEpicUsage() {
282
307
  "UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
283
308
  "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
284
309
  "repository base branch stays human-gated. Pass --attended to opt out. An",
285
- "unattended run has two one-time repository prerequisites (consent, and a",
286
- "verified notify webhook default) that readiness lists with their state and that",
287
- "are refused BY NAME before any run is created.",
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.",
314
+ "",
315
+ "Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
316
+ "created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
317
+ "this checkout's Git top level. It claims only that run's jobs, logs to",
318
+ ".bridge/executors/<run>.log, and exits on its own when the run is done or",
319
+ "abandoned. Re-running for the same live run starts no second one. A durable",
320
+ "launch profile starts none: it requires a fresh --repo-wide executor instead.",
321
+ "Checks that need no run happen FIRST, so a refusal before the run is created",
322
+ "creates nothing; an executor that fails to start after it leaves the run intact",
323
+ "and prints the log path and the exact command to start it by hand.",
324
+ "",
325
+ "Reconciler liveness is read from the deployment's heartbeat. Against a target",
326
+ "that is not this machine's plane (production, say), drive-epic never touches a",
327
+ "local plane: a target with no live reconciler is refused by name.",
288
328
  "",
289
329
  "One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
290
- "starts the conductor runtime for you when readiness is missing ONLY the",
291
- "reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
292
- "reconciler), creates and approves the run, then starts executor lanes SCOPED to",
293
- "that run, and starts the dead-man observer last — one invocation, no manual",
294
- "`plane up` first. A report missing supervisor setup, supervisor configuration,",
295
- "GitHub App credentials, or an unattended prerequisite is NOT started: those are",
296
- "operator-owned, and drive-epic exits with that one named reason instead.",
330
+ "starts the conductor runtime for you against the local plane endpoint, when",
331
+ "readiness is missing ONLY the reconciler. It brings up the CONTROL PLANE first",
332
+ "(server and reconciler), creates and approves the run, then starts executor lanes",
333
+ "SCOPED to that run, and starts the dead-man observer last — one invocation, no",
334
+ "manual `plane up` first. A report missing supervisor setup,",
335
+ "supervisor configuration, GitHub App credentials, or an unattended prerequisite",
336
+ "is NOT started: those are operator-owned, and drive-epic exits with that one",
337
+ "named reason instead.",
297
338
  "",
298
339
  "The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
299
340
  `local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
@@ -305,18 +346,42 @@ export function getDriveEpicUsage() {
305
346
  " - refusal: anything else holds the port (another listener, a manifest for a",
306
347
  " different repository, an unreadable manifest, a plane that cannot take a",
307
348
  " run) — one named reason, and nothing is started.",
308
- "One epic per plane: a plane already bound to a different or finished run is",
309
- "refused as plane-already-bound. Executor lanes are always scoped to the one",
310
- "run with --epic-run-id, on every path.",
349
+ "One epic per plane — for its LANES: a plane's lanes serve the one run it is",
350
+ "bound to. A second run attaches to the same plane's reconciler and gets its own",
351
+ "run-scoped executor instead. A plane whose bound run cannot be read is refused",
352
+ "as plane-already-bound. Concurrent planes on one database are not supported.",
353
+ "Executor lanes and run-scoped executors are always scoped to the one run with",
354
+ "--epic-run-id, on every path.",
355
+ "",
356
+ "Finding the plan (BAPI-1285): without --plan-file, drive-epic searches",
357
+ "{docs_dir}/epic-plans/*/epic-plan.meta.json for a file naming this epic",
358
+ "({\"epic_key\": \"<KEY>\"}), where docs_dir is BAPI_DOCS_DIR or docs/tmp",
359
+ "resolved against the Git top level. Exactly one match with a sibling",
360
+ "epic-plan.dag.json is used as if you had passed --plan-file, and its path is",
361
+ "printed before anything is created. It never guesses: no match, two or more",
362
+ "matches, or an unusable meta file stops with the paths it found and creates",
363
+ "nothing. It never matches by folder name, child ticket key, or manifest. A",
364
+ "supplied --plan-file always wins, and discovery does not run. The meta file is",
365
+ "written by `emit-epic-implementer-bundle finalize`; a plan finalized before",
366
+ "that has none, so pass its DAG with --plan-file.",
367
+ "",
368
+ "A live run with no plan: when the epic already has a live run and no plan was",
369
+ "supplied or found, drive-epic does not create anything. It makes sure that run",
370
+ "has an executor (reusing a live one, never starting a second) and reports it. A",
371
+ "durable launch profile starts none and relies on its host's confirmed repo-wide",
372
+ "executor; a plane already bound to the run needs none. An epic with several active runs, or",
373
+ "a run that cannot be read, is refused by name.",
311
374
  "",
312
375
  "Preconditions are validated, never repaired: the current directory must be a Git",
313
- "work tree. No worktree is created for you and no replacement port is chosen for",
314
- "you — each failure names the fix. Fix it and rerun.",
376
+ "work tree inside the target repository, because the run's executor starts there.",
377
+ "No worktree is created for you and no replacement port is chosen for you — each",
378
+ "failure names the fix. Fix it and rerun.",
315
379
  "",
316
380
  "Options:",
317
- " --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
318
- " selected, drive-epic runs that bootstrap directly",
319
- " instead of printing the command to run.",
381
+ " --plan-file <path> Plan DAG sidecar. Optional: without it, drive-epic finds",
382
+ " the plan by epic key (see above). A supplied path always",
383
+ " wins, and when the v2 path is selected drive-epic runs",
384
+ " the bootstrap directly with it.",
320
385
  " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
321
386
  " --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
322
387
  " verbatim, never interpreted here.",
@@ -333,6 +398,32 @@ export function getDriveEpicUsage() {
333
398
  " --review-policy <src> PER-RUN review policy source.",
334
399
  " --replace-policy Authorize replacing a LIVE run's stored policy.",
335
400
  "",
401
+ "Launch target (BAPI-1260) — decided BEFORE any authenticated request:",
402
+ " --launch-profile <name> Select the host-local launch profile guided setup",
403
+ " approved (`install conductor --launch-profile <name>`):",
404
+ " target, repository, mode, runtime, and policy and",
405
+ " credential references. Useful for a local or other",
406
+ " non-production server and for durable hosts.",
407
+ " --base-url <url> Explicit Bridge API target. With a profile it must",
408
+ " agree with it — an override never rebinds a profile.",
409
+ "Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
410
+ `targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
411
+ "stored bapi:<repo> key when one is available. A stored key approved for a",
412
+ "different endpoint is never sent; an unapproved key is sent only to",
413
+ "production.",
414
+ "",
415
+ "Preview (BAPI-1285):",
416
+ " --dry-run Read-only launch preview. Resolves the target, reads",
417
+ " readiness and the epic's live run, discovers the plan,",
418
+ " and runs the bootstrap's own dry-run validation. It",
419
+ " reports what a real run WOULD do (create, attach,",
420
+ " refuse, or stop) and never creates a run, cuts a",
421
+ " branch, starts a plane, requests a lane, or starts or",
422
+ " signals an executor. Exit 0 for create or attach, 1 for",
423
+ " refuse or stop.",
424
+ " --json With --dry-run only: print exactly one JSON document on",
425
+ " stdout and nothing else. Alone, it is a parse error.",
426
+ "",
336
427
  " -h, --help Show this help",
337
428
  ].join("\n");
338
429
  }
@@ -359,6 +450,9 @@ export function parseDriveEpicArgs(argv) {
359
450
  let policyFile;
360
451
  let reviewPolicy;
361
452
  let replacePolicy = false;
453
+ // BAPI-1260 launch selection.
454
+ let launchProfile;
455
+ let baseUrl;
362
456
  // BAPI-1009: validated with the SHARED validator setup-epic uses, so a name
363
457
  // drive-epic accepts is exactly a name setup-epic accepts. A second rule here
364
458
  // would let a name pass the router and fail the delegate.
@@ -373,8 +467,19 @@ export function parseDriveEpicArgs(argv) {
373
467
  return trimmed;
374
468
  };
375
469
  let attended = false;
470
+ // BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
471
+ let dryRun = false;
472
+ let json = false;
376
473
  for (let i = 0; i < argv.length; i++) {
377
474
  const arg = argv[i];
475
+ if (arg === "--dry-run") {
476
+ dryRun = true;
477
+ continue;
478
+ }
479
+ if (arg === "--json") {
480
+ json = true;
481
+ continue;
482
+ }
378
483
  if (arg === "--into-base") {
379
484
  intoBase = true;
380
485
  continue;
@@ -394,7 +499,9 @@ export function parseDriveEpicArgs(argv) {
394
499
  arg === "--repo" ||
395
500
  arg === "--feature-branch" ||
396
501
  arg === "--policy-file" ||
397
- arg === "--review-policy") {
502
+ arg === "--review-policy" ||
503
+ arg === "--launch-profile" ||
504
+ arg === "--base-url") {
398
505
  const value = argv[i + 1];
399
506
  if (value === undefined || value.startsWith("-")) {
400
507
  return { status: "error", message: `${arg} requires a value.` };
@@ -407,6 +514,10 @@ export function parseDriveEpicArgs(argv) {
407
514
  policyFile = value;
408
515
  else if (arg === "--review-policy")
409
516
  reviewPolicy = value;
517
+ else if (arg === "--launch-profile")
518
+ launchProfile = value;
519
+ else if (arg === "--base-url")
520
+ baseUrl = value;
410
521
  else if (takeFeatureBranch(value) === null) {
411
522
  return {
412
523
  status: "error",
@@ -416,7 +527,7 @@ export function parseDriveEpicArgs(argv) {
416
527
  i++;
417
528
  continue;
418
529
  }
419
- const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy)=(.*)$/);
530
+ const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy|--launch-profile|--base-url)=(.*)$/);
420
531
  if (eq) {
421
532
  const value = eq[2];
422
533
  if (eq[1] === "--feature-branch") {
@@ -437,6 +548,10 @@ export function parseDriveEpicArgs(argv) {
437
548
  policyFile = value;
438
549
  else if (eq[1] === "--review-policy")
439
550
  reviewPolicy = value;
551
+ else if (eq[1] === "--launch-profile")
552
+ launchProfile = value;
553
+ else if (eq[1] === "--base-url")
554
+ baseUrl = value;
440
555
  else
441
556
  repo = value;
442
557
  continue;
@@ -446,6 +561,15 @@ export function parseDriveEpicArgs(argv) {
446
561
  }
447
562
  positionals.push(arg);
448
563
  }
564
+ // BAPI-1285 — same style as setup-epic's `--local-only` needs `--dry-run` rule,
565
+ // refused before any dependency is touched.
566
+ if (json && !dryRun) {
567
+ return {
568
+ status: "error",
569
+ message: "--json is only valid together with --dry-run. It selects the machine-readable " +
570
+ "form of the read-only launch preview, and a real launch has no JSON output.",
571
+ };
572
+ }
449
573
  // BAPI-1009: refused HERE — before access resolution, before the readiness
450
574
  // request, and before any delegation — with the same corrective wording
451
575
  // setup-epic uses, so the two entry points explain one mistake one way.
@@ -458,6 +582,22 @@ export function parseDriveEpicArgs(argv) {
458
582
  "may be selected — remove whichever one you did not mean.",
459
583
  };
460
584
  }
585
+ // BAPI-1260 — the launch selection is validated for SHAPE here, before any
586
+ // side effect. Whether it agrees with the profile and the environment is
587
+ // decided by the target resolver, still before any authenticated request.
588
+ if (launchProfile !== undefined && !EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(launchProfile)) {
589
+ return {
590
+ status: "error",
591
+ message: "--launch-profile must match [a-z0-9][a-z0-9._-]{0,62}.",
592
+ };
593
+ }
594
+ if (baseUrl !== undefined) {
595
+ const validated = validateHttpBaseUrl(baseUrl);
596
+ if (!validated.ok) {
597
+ return { status: "error", message: describeBaseUrlRejection("--base-url", validated.reason) };
598
+ }
599
+ baseUrl = validated.baseUrl;
600
+ }
461
601
  if (positionals.length === 0) {
462
602
  return { status: "error", message: "Missing required epic key." };
463
603
  }
@@ -485,6 +625,10 @@ export function parseDriveEpicArgs(argv) {
485
625
  ...(policyFile ? { policyFile } : {}),
486
626
  ...(reviewPolicy ? { reviewPolicy } : {}),
487
627
  ...(replacePolicy ? { replacePolicy: true } : {}),
628
+ ...(launchProfile ? { launchProfile } : {}),
629
+ ...(baseUrl ? { baseUrl } : {}),
630
+ ...(dryRun ? { dryRun: true } : {}),
631
+ ...(json ? { json: true } : {}),
488
632
  };
489
633
  return { status: "ok", options };
490
634
  }
@@ -511,7 +655,7 @@ export function parseDriveEpicArgs(argv) {
511
655
  * about to be created.
512
656
  */
513
657
  export function selectConductor(readiness) {
514
- const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
658
+ const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && req.advisoryOnly !== true && !req.satisfied(readiness));
515
659
  if (missing.length === 0) {
516
660
  return {
517
661
  kind: "selected",
@@ -537,7 +681,7 @@ export function selectConductor(readiness) {
537
681
  * no quoting to get wrong.
538
682
  */
539
683
  export function buildSetupEpicArgv(options) {
540
- const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
684
+ const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, } = options;
541
685
  return [
542
686
  "--epic-key",
543
687
  epicKey,
@@ -551,37 +695,94 @@ export function buildSetupEpicArgv(options) {
551
695
  ...(policyFile ? ["--policy-file", policyFile] : []),
552
696
  ...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
553
697
  ...(replacePolicy ? ["--replace-policy"] : []),
698
+ // BAPI-1260 — the SAME launch selection, so setup-epic resolves the same
699
+ // target and bound credential drive-epic just did. Appended only when
700
+ // supplied, so every pre-existing argv stays byte-identical. A flag-less
701
+ // (production-default, BAPI-1272) run forwards neither: setup-epic reads
702
+ // the adopted BAPI_BASE_URL instead.
703
+ ...(launchProfile ? ["--launch-profile", launchProfile] : []),
704
+ ...(baseUrl ? ["--base-url", baseUrl] : []),
554
705
  ];
555
706
  }
556
707
  /**
557
- * Render the selected v2 path and only that path.
708
+ * Render the no-plan stop: what discovery found, how to make a plan, and how to
709
+ * re-run with one.
558
710
  *
559
- * Precondition: the caller has already ruled out delegation and a not-ready
560
- * result. The `v2` branch reads as "green, but no plan file was supplied", which
561
- * is only true once {@link runDriveEpicCli} has declined to delegate — so route
562
- * first, render second.
711
+ * Precondition: the caller has already ruled out delegation, a not-ready result,
712
+ * AND a live run. This runs only after plan discovery came back empty or
713
+ * ambiguous (BAPI-1285), so it never claims "nothing derives a plan from an epic
714
+ * key" — something does, and it names exactly what it found.
715
+ *
716
+ * It offers two ways to re-run and only one of them is a runnable `drive-epic`
717
+ * command: `/drive-epic-run` is a session slash command, not a second conductor.
718
+ */
719
+ export function renderConductorHandoff(selection, epicKey,
720
+ /**
721
+ * BAPI-1260 — the selected launch profile and the options this invocation
722
+ * carried. With a profile, the re-run command selects it and carries every
723
+ * non-secret flag it needs, shell-quoted, so it runs unchanged in a fresh
724
+ * shell. The plan file is the one thing that cannot be filled in yet, and it
725
+ * is the only placeholder left.
726
+ */
727
+ launch = { profile: null },
728
+ /**
729
+ * BAPI-1285 — what discovery found. Absent reads as "no metadata named this
730
+ * epic", with the searched directory left as its placeholder.
563
731
  */
564
- export function renderConductorHandoff(selection, epicKey) {
565
- const invocation = `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
732
+ discovery) {
733
+ const invocation = launch.profile !== null
734
+ ? renderEpicLaunchCommand(launch.profile, {
735
+ epicKey,
736
+ planFile: "<path>",
737
+ ...(launch.options?.policyFile ? { policyFile: launch.options.policyFile } : {}),
738
+ ...(launch.options?.attended ? { attended: true } : {}),
739
+ ...(launch.options?.intoBase ? { intoBase: true } : {}),
740
+ ...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
741
+ }).replace(`${shellQuote("<path>")}`, "<path>")
742
+ : `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
743
+ const plansDir = discovery?.epic_plans_dir ?? "{docs_dir}/epic-plans";
744
+ const outcome = discovery?.status === "plan_ambiguous"
745
+ ? [
746
+ `More than one plan names ${epicKey} under ${plansDir}, and drive-epic does not guess`,
747
+ "between them. Candidates:",
748
+ ...discovery.candidates.map((candidate) => ` - ${candidate}`),
749
+ ]
750
+ : [`No epic-plan.meta.json under ${plansDir} names ${epicKey}.`];
751
+ const skipped = discovery !== undefined && discovery.skipped.length > 0
752
+ ? [
753
+ "",
754
+ "These metadata files could not be used, so they were not counted:",
755
+ ...discovery.skipped.map((entry) => ` - ${entry.path} (${entry.reason})`),
756
+ ]
757
+ : [];
566
758
  return {
567
759
  conductor: "v2",
568
760
  invocation,
569
761
  lines: [
570
- `${epicKey}: ${selection.reason}, but no plan file was supplied.`,
762
+ `${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
763
+ "",
764
+ ...outcome,
765
+ ...skipped,
766
+ "",
767
+ "Discovery reads only epic-plan.meta.json ({\"epic_key\": ...}). It never matches a",
768
+ "folder name, a child ticket key, or a manifest. A plan finalized before that file",
769
+ "existed has none; find an older plan's DAG with:",
770
+ ` ${plansDir}/*/epic-plan.dag.json`,
571
771
  "",
572
- "The engine path needs a plan DAG, and nothing derives one from an epic key:",
573
- "the dependency edges and per-ticket touched_files it carries do not exist",
574
- "anywhere else. Produce one, then re-run this command with it.",
772
+ "The engine path needs a plan DAG: the dependency edges and per-ticket",
773
+ "touched_files it carries do not exist anywhere else.",
575
774
  "",
576
- ` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
775
+ ` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
577
776
  " sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
578
777
  " If the epic's tickets already exist, run emit-epic-implementer-bundle",
579
778
  " finalize instead — it resolves the placeholder keys in an existing",
580
- " sidecar and attaches touched_files.",
779
+ " sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
780
+ " plan is found next time.",
581
781
  "",
582
- ` 2. ${invocation}`,
782
+ ` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
783
+ ` or from a shell: ${invocation}`,
583
784
  "",
584
- `That second command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
785
+ `That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
585
786
  "something you invoke directly.",
586
787
  ],
587
788
  };
@@ -608,6 +809,16 @@ export function assertSingleConductorInvocation(text) {
608
809
  throw new Error(`drive-epic emitted more than one conductor invocation (${named.join(", ")}). ` +
609
810
  "Exactly one path may ever be presented.");
610
811
  }
812
+ // BAPI-1260 — the entry point itself, too. A rendered handoff now carries a
813
+ // complete, runnable `drive-epic <KEY> ...` command; two DIFFERENT runnable
814
+ // commands (say, one per profile or target) would hand the reader a choice of
815
+ // authority just as surely as naming two conductors. The same command
816
+ // 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()));
818
+ if (runnable.size > 1) {
819
+ throw new Error(`drive-epic emitted ${runnable.size} different runnable launch commands. ` +
820
+ "Exactly one path may ever be presented.");
821
+ }
611
822
  }
612
823
  /**
613
824
  * Conservative bound on any single advisory field. Exists so malformed or
@@ -807,16 +1018,100 @@ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
807
1018
  memberEnv.DYNO = env.DYNO;
808
1019
  return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
809
1020
  }
810
- /** Terminal `epic_runs.status` values; every other status is a live run. */
811
- const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
1021
+ /** The BAPI-1260 launch selection carried by parsed options. */
1022
+ function launchSelectionOf(options) {
1023
+ return {
1024
+ ...(options.launchProfile ? { profileName: options.launchProfile } : {}),
1025
+ ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
1026
+ };
1027
+ }
1028
+ /**
1029
+ * BAPI-1285 — resolve the docs directory discovery searches, through two seams.
1030
+ *
1031
+ * `BAPI_DOCS_DIR` is read once. An absolute value is used as given and needs no
1032
+ * Git call. Otherwise the value — or `docs/tmp` — is resolved against the Git top
1033
+ * level. When Git cannot name a top level this REFUSES rather than substituting
1034
+ * `cwd`: silently searching the wrong directory would report "no plan" for a plan
1035
+ * that exists.
1036
+ *
1037
+ * `index.ts` (project root) and the bundle CLI (`cwd`) resolve the same default
1038
+ * slightly differently today; in the normal case all three equal the Git top
1039
+ * level, and neither of those is changed here.
1040
+ */
1041
+ export async function resolveDriveEpicDocsDir(seams) {
1042
+ const raw = seams.env.BAPI_DOCS_DIR;
1043
+ const configured = raw !== undefined && raw.trim() !== "" ? raw : undefined;
1044
+ if (configured !== undefined && path.isAbsolute(configured)) {
1045
+ return { ok: true, docsDir: configured };
1046
+ }
1047
+ let top;
1048
+ try {
1049
+ top = await seams.gitTopLevel();
1050
+ }
1051
+ catch {
1052
+ return { ok: false };
1053
+ }
1054
+ if (top === null || top.trim() === "")
1055
+ return { ok: false };
1056
+ return { ok: true, docsDir: path.resolve(top, configured ?? path.join("docs", "tmp")) };
1057
+ }
1058
+ /** The real filesystem behind plan discovery. */
1059
+ function createDefaultPlanDiscoveryFs() {
1060
+ return {
1061
+ readdir: (dirPath) => readdir(dirPath, { withFileTypes: true }),
1062
+ readFile: (filePath) => readFile(filePath, "utf8"),
1063
+ };
1064
+ }
1065
+ /**
1066
+ * BAPI-1285 — map the by-epic-key run read the way `setup-epic`'s Step 0 does:
1067
+ * 404 is no run, 409 is a wedged epic, and every other failure is unknown.
1068
+ * Nothing but a status and an error kind is ever kept from a failure.
1069
+ */
1070
+ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalThis.fetch) {
1071
+ try {
1072
+ const state = await fetchEpicRunState(access, epicKey, fetchImpl);
1073
+ const run = state
1074
+ ?.epic_run;
1075
+ if (typeof run?.epic_run_id !== "string" ||
1076
+ run.epic_run_id === "" ||
1077
+ typeof run.status !== "string") {
1078
+ return { kind: "unknown", reason: "the run state response was malformed" };
1079
+ }
1080
+ return { kind: "live", epicRunId: run.epic_run_id, status: run.status };
1081
+ }
1082
+ catch (err) {
1083
+ if (err instanceof ConductorBridgeApiError) {
1084
+ if (err.status === 404)
1085
+ return { kind: "none" };
1086
+ if (err.status === 409)
1087
+ return { kind: "conflict" };
1088
+ return {
1089
+ kind: "unknown",
1090
+ reason: `kind=${err.kind}${typeof err.status === "number" ? ` status=${err.status}` : ""}`,
1091
+ };
1092
+ }
1093
+ return { kind: "unknown", reason: "the run state read failed unexpectedly" };
1094
+ }
1095
+ }
812
1096
  export function createDefaultDriveEpicDeps() {
813
1097
  return {
814
- resolveAccess: async (repo, cwd) => {
815
- const result = await resolveConductorBridgeApiAccess({
1098
+ resolveAccess: async (repo, cwd, launch) => {
1099
+ // BAPI-1260: the target-first launch contract. BAPI-1272: with no target
1100
+ // selected it defaults to production, and a stored key is sent there only
1101
+ // when it is unbound or bound to production — never one bound elsewhere.
1102
+ const result = await resolveEpicLaunchBridgeApiAccess(launch ?? {}, {
816
1103
  ...(repo ? { repoName: repo } : {}),
817
1104
  ...(cwd ? { cwd } : {}),
818
1105
  });
819
- return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
1106
+ return result.ok
1107
+ ? { ok: true, access: result.access, profile: result.profile }
1108
+ : { ok: false, error: result.error };
1109
+ },
1110
+ // Non-secret, and only ever a value the resolver just agreed with the
1111
+ // environment on (a conflicting BAPI_BASE_URL was refused above), so the
1112
+ // processes this command starts see the same target it resolved.
1113
+ adoptLaunchTarget: (baseUrl) => {
1114
+ process.env.BAPI_BASE_URL = baseUrl;
820
1115
  },
821
1116
  readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
822
1117
  runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
@@ -861,6 +1156,39 @@ export function createDefaultDriveEpicDeps() {
861
1156
  return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
862
1157
  },
863
1158
  readPlaneManifest: (repoRoot) => readPlaneManifest(repoRoot, createPlaneFsDeps()),
1159
+ resolveDocsDir: () => resolveDriveEpicDocsDir({
1160
+ env: process.env,
1161
+ gitTopLevel: async () => {
1162
+ const top = await createDefaultRunExecutorLaunchDeps().execFile("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() });
1163
+ return top.ok ? top.stdout.trim() : null;
1164
+ },
1165
+ }),
1166
+ discoveryFs: createDefaultPlanDiscoveryFs(),
1167
+ readPlanDag: (filePath) => readFile(filePath, "utf8"),
1168
+ readPolicyFile: (filePath) => readFile(filePath, "utf8"),
1169
+ readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
1170
+ previewSetupEpic: async ({ argv, baseUrl }) => {
1171
+ const stdout = [];
1172
+ const stderr = [];
1173
+ const result = await runSetupEpicWorkflow(argv, {
1174
+ // A COPY carrying the resolved target: the preview never mutates
1175
+ // `process.env`, so a dry run leaves the process exactly as it found it.
1176
+ env: { ...process.env, BAPI_BASE_URL: baseUrl },
1177
+ log: (message) => stdout.push(message),
1178
+ errorLog: (message) => stderr.push(message),
1179
+ });
1180
+ return { exitCode: result.exitCode, stdout, stderr };
1181
+ },
1182
+ // Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
1183
+ // locates the package entrypoint, and starts nothing. It is reached through
1184
+ // its module function here, never through the `prepareRunExecutor` dep the
1185
+ // preview must not call.
1186
+ inspectRunExecutorPrecondition: async (cwd) => {
1187
+ const result = await prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps());
1188
+ return result.ok ? { ok: true } : { ok: false, message: result.message };
1189
+ },
1190
+ prepareRunExecutor: (cwd) => prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps()),
1191
+ ensureRunExecutor: (args) => ensureRunExecutorDefault(args, createDefaultRunExecutorLaunchDeps()),
864
1192
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
865
1193
  // construction, so nothing has claimed stdout for the protocol transport.
866
1194
  log: (message) => console.log(message),
@@ -887,12 +1215,74 @@ export async function runDriveEpicCli(argv, overrides = {}) {
887
1215
  return 1;
888
1216
  }
889
1217
  const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
1218
+ const launch = launchSelectionOf(parsed.options);
1219
+ // BAPI-1285 — the read-only preview is its own path from here. It shares the
1220
+ // classification and refusal builders below and none of the launch code.
1221
+ if (parsed.options.dryRun === true) {
1222
+ return await runDriveEpicPreview(deps, parsed.options);
1223
+ }
890
1224
  try {
1225
+ // Every async branch below is `return await`ed. A bare `return promise`
1226
+ // inside this `try` would let a rejection skip the sanitizing `catch` at the
1227
+ // bottom and escape with its raw message (BAPI-1279).
891
1228
  // --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
892
- const accessResult = await deps.resolveAccess(repo);
1229
+ // BAPI-1260: the TARGET is decided first — from --launch-profile,
1230
+ // --base-url, and BAPI_BASE_URL, else production (BAPI-1272) — and only
1231
+ // then is a credential bound to that target read. A conflicting target or
1232
+ // an unusable credential refuses here, before any authenticated request;
1233
+ // there is no fallback to another endpoint. The adopted target becomes the
1234
+ // child processes' BAPI_BASE_URL.
1235
+ const accessResult = await deps.resolveAccess(repo, undefined, launch);
893
1236
  if (!accessResult.ok) {
894
1237
  return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
895
1238
  }
1239
+ const launchProfile = accessResult.profile ?? null;
1240
+ deps.adoptLaunchTarget?.(accessResult.access.baseUrl);
1241
+ // BAPI-1279 — decided ONCE, right here. A target that is not this machine's
1242
+ // plane endpoint never reaches a local-plane operation: no preflight, no
1243
+ // launch, no lane request, no manifest read, no port probe. Every branch
1244
+ // below reads this one value rather than re-deriving it.
1245
+ const planeTarget = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null;
1246
+ // `let`: a plan found by epic key (BAPI-1285) is folded into the options, after
1247
+ // which it is indistinguishable from a supplied `--plan-file`.
1248
+ let ctx = {
1249
+ deps,
1250
+ epicKey,
1251
+ options: parsed.options,
1252
+ access: accessResult.access,
1253
+ profile: launchProfile,
1254
+ };
1255
+ // BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
1256
+ // discovered plan must count as a supplied one for the BAPI-1153 cold-start
1257
+ // case below (a local plane that is not running yet is the commonest first
1258
+ // launch), and discovery is local, read-only, and free. What it may NOT do is
1259
+ // decide anything before readiness: an unresolvable docs root is only
1260
+ // remembered here and refused after readiness, so unknown readiness still
1261
+ // escalates exactly as before, and the live-run read stays after readiness.
1262
+ let discovered = null;
1263
+ let docsRootUnresolved = false;
1264
+ if (planFile === undefined) {
1265
+ const docs = await deps.resolveDocsDir();
1266
+ if (docs.ok) {
1267
+ discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
1268
+ if (discovered.status === "found") {
1269
+ ctx = { ...ctx, options: { ...ctx.options, planFile: discovered.path } };
1270
+ }
1271
+ }
1272
+ else {
1273
+ docsRootUnresolved = true;
1274
+ }
1275
+ }
1276
+ let foundPlanAnnounced = false;
1277
+ /** Names the found plan once, BEFORE anything is created, and surfaces its advisory. */
1278
+ const announceFoundPlan = async (surfaceAdvisory = true) => {
1279
+ if (foundPlanAnnounced || discovered?.status !== "found")
1280
+ return;
1281
+ foundPlanAnnounced = true;
1282
+ deps.log(`${epicKey}: using the plan found by epic key: ${discovered.path}`);
1283
+ if (surfaceAdvisory)
1284
+ await surfaceContainmentAdvisory(deps, discovered.path);
1285
+ };
896
1286
  // --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
897
1287
  let readiness;
898
1288
  try {
@@ -905,17 +1295,25 @@ export async function runDriveEpicCli(argv, overrides = {}) {
905
1295
  // Everything else — a non-plane base URL, an HTTP or auth or shape error, a
906
1296
  // timeout, any other probe result — keeps today's escalation byte for byte,
907
1297
  // because there unknown is still fatal (R14).
908
- if (planFile !== undefined && err instanceof ConductorBridgeApiError && err.kind === "network") {
1298
+ if (ctx.options.planFile !== undefined &&
1299
+ planeTarget &&
1300
+ err instanceof ConductorBridgeApiError &&
1301
+ err.kind === "network") {
909
1302
  const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
1303
+ // BAPI-1260 — only a SELECTED LOCAL plane is ever started here. A
1304
+ // profile approved for durable execution names an authority this
1305
+ // machine does not own, so a refused port is that authority being
1306
+ // down — never permission to launch a local replacement.
1307
+ if (endpoint !== null && launchProfile !== null && launchProfile.executionMode !== "local") {
1308
+ return escalate(deps, epicKey, `launch profile '${launchProfile.name}' selects durable execution, and its control plane is ` +
1309
+ "not answering; no local replacement was started");
1310
+ }
910
1311
  if (endpoint !== null) {
911
1312
  const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
912
1313
  if (probe.kind === "refused") {
913
- return coldStartPlane({
914
- deps,
915
- epicKey,
916
- options: parsed.options,
917
- access: accessResult.access,
918
- });
1314
+ // `coldStartPlane` surfaces the containment advisory itself.
1315
+ await announceFoundPlan(false);
1316
+ return await coldStartPlane(ctx);
919
1317
  }
920
1318
  }
921
1319
  }
@@ -931,6 +1329,36 @@ export async function runDriveEpicCli(argv, overrides = {}) {
931
1329
  // delegated argv, or the exit code.
932
1330
  if (planFile)
933
1331
  await surfaceContainmentAdvisory(deps, planFile);
1332
+ // BAPI-1285 — no `--plan-file`: find the plan from the epic key, and read the
1333
+ // epic's live run. Readiness has already been resolved (unknown escalated
1334
+ // above), so nothing here can be a discovery-driven create on unknown.
1335
+ //
1336
+ // A supplied plan always wins and skips ALL of this, including the extra
1337
+ // live-run read: setup-epic does its own Step 0 read and owns reuse.
1338
+ let discoveryStop;
1339
+ if (planFile === undefined) {
1340
+ if (docsRootUnresolved || discovered === null) {
1341
+ return refuse(deps, docsRootRefusal(epicKey).lines);
1342
+ }
1343
+ const live = await deps.readLiveEpicRun(accessResult.access, epicKey);
1344
+ // Wedged or unreadable: never a create and never an attach, whatever
1345
+ // discovery found. Guessing here is how a duplicate run gets minted.
1346
+ if (live.kind === "conflict")
1347
+ return refuse(deps, liveRunAmbiguousRefusal(epicKey).lines);
1348
+ if (live.kind === "unknown")
1349
+ return refuse(deps, liveRunUnknownRefusal(epicKey, live.reason).lines);
1350
+ if (discovered.status === "found") {
1351
+ // The plan was folded into `ctx.options` before readiness; from here it is
1352
+ // indistinguishable from a supplied `--plan-file`.
1353
+ await announceFoundPlan();
1354
+ }
1355
+ else if (live.kind === "live") {
1356
+ return await attachToLiveRun(ctx, readiness, live);
1357
+ }
1358
+ else {
1359
+ discoveryStop = discovered;
1360
+ }
1361
+ }
934
1362
  // Decide delegation BEFORE rendering. The v2 branch's text is specifically
935
1363
  // about a missing plan file, and rendering it on the path that has one
936
1364
  // would produce a message contradicting what is about to happen.
@@ -940,7 +1368,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
940
1368
  // not-ready result can be rendered, because a report that is non-green only on
941
1369
  // the runtime facts is not a "v2 cannot run" situation — it is a plane that
942
1370
  // has not been started yet.
943
- if (planFile) {
1371
+ if (ctx.options.planFile !== undefined) {
944
1372
  // BAPI-1102 — the explicit `--attended` opt-out makes the unattended
945
1373
  // prerequisites inapplicable. Both inputs are FLAG checks, not policy
946
1374
  // reads: whatever a `--policy-file` says stays opaque here, exactly as it
@@ -951,12 +1379,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
951
1379
  intoBase: parsed.options.intoBase === true,
952
1380
  hasPolicyFile: parsed.options.policyFile !== undefined,
953
1381
  });
954
- const setupArgv = buildSetupEpicArgv(parsed.options);
955
- // Green: delegate exactly as before. No plane invocation, no preflight,
956
- // and byte-identical argv for the no-flag case. Only the structured
957
- // result's exit code is returned, so the public contract is unchanged.
1382
+ // Green: create the run, then make sure something will claim its jobs
1383
+ // (BAPI-1279). Before this, green delegated to setup-epic and nothing
1384
+ // else, so a production launch — or any second run — created a run no
1385
+ // executor would ever claim. setup-epic's argv is unchanged.
958
1386
  if (classification.kind === "green") {
959
- return (await deps.runSetupEpic(setupArgv)).exitCode;
1387
+ if (!planeTarget) {
1388
+ return await createRunWithRunExecutor(ctx, readiness);
1389
+ }
1390
+ // A plane target: attach to THIS checkout's plane when it is the one
1391
+ // listening (its lanes, or a dedicated executor for a second run).
1392
+ // Otherwise — no plane here, or one owned by a sibling worktree — the
1393
+ // run gets a run-scoped executor. The preflight runs only when a
1394
+ // manifest says there is a plane in this checkout to attach to.
1395
+ const attachable = await findAttachablePlane(ctx);
1396
+ if (attachable !== null) {
1397
+ return await attachToPlane(ctx, attachable, readiness);
1398
+ }
1399
+ return await createRunWithRunExecutor(ctx, readiness);
960
1400
  }
961
1401
  // Operator-blocked: ONE named reason, and nothing is started. `plane up`
962
1402
  // cannot create stored supervisor state or resolve GitHub App credentials,
@@ -970,8 +1410,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
970
1410
  // The requirement's OWN remedy when it has one. Telling an operator to
971
1411
  // run `install-bridge conductor` for a missing consent flag sends them
972
1412
  // to a command that cannot set it.
973
- classification.remedy ??
974
- "Resolve it with `install-bridge conductor`, then run drive-epic again.",
1413
+ operatorBlockedRemedy(classification),
975
1414
  ].join("\n");
976
1415
  // Named as a fixed reason and nothing else: no conductor is offered here,
977
1416
  // because handing an operator a second authority is the hazard this
@@ -980,6 +1419,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
980
1419
  deps.errorLog(refusal);
981
1420
  return 1;
982
1421
  }
1422
+ // BAPI-1279 — runtime-only against a target that is NOT this machine's
1423
+ // plane: starting anything here could not help, because the missing
1424
+ // reconciler belongs to that deployment. One named reason, and nothing
1425
+ // local is touched.
1426
+ if (!planeTarget) {
1427
+ return refuseRemoteRuntime(ctx, readiness);
1428
+ }
983
1429
  // Runtime-only: this invocation composes bring-up with run creation.
984
1430
  const repoRoot = deps.cwd();
985
1431
  const preflight = await deps.runPlanePreflight(repoRoot);
@@ -990,8 +1436,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
990
1436
  // preflight made from its single manifest read; nothing is re-read here.
991
1437
  const existingPlane = preflight.observations?.existingPlane;
992
1438
  const attachManifest = existingPlane?.kind === "attachable" &&
993
- preflight.observations?.serverPort === "connected" &&
994
- planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
1439
+ preflight.observations?.serverPort === "connected"
995
1440
  ? existingPlane.manifest
996
1441
  : null;
997
1442
  // Composition preconditions FIRST, and only these two. Preflight is
@@ -1019,7 +1464,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1019
1464
  return 1;
1020
1465
  }
1021
1466
  if (attachManifest !== null) {
1022
- return attachToPlane({ deps, epicKey, options: parsed.options, access: accessResult.access }, attachManifest, readiness);
1467
+ return await attachToPlane(ctx, attachManifest, readiness);
1023
1468
  }
1024
1469
  // ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
1025
1470
  //
@@ -1040,23 +1485,21 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1040
1485
  const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1041
1486
  if (!controlPlane.ok) {
1042
1487
  // The launch already reported why, in its own words, and it rolls its own
1043
- // startup back. Nothing to add, and no run was created here.
1044
- for (const line of controlPlane.lines)
1045
- deps.errorLog(line);
1046
- return 1;
1488
+ // startup back. No run was created here.
1489
+ return reportControlPlaneLaunchFailure(ctx, controlPlane);
1047
1490
  }
1048
1491
  // Held so the launcher's lifetime promise is never unobserved. It settles
1049
1492
  // when the PLANE comes down, which is long after this command returns —
1050
1493
  // the detached runtime is its own process-group leader and outlives us by
1051
1494
  // design, which is what "the plane is left running" means.
1052
1495
  void controlPlane.lifetime.catch(() => undefined);
1053
- // Readiness is NOT re-read as a gate here, and that has not changed:
1054
- // `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
1055
- // and `executor_jobs` rows — tables that are EMPTY on a repository that has
1056
- // never run an epic — so gating on a re-read would hang the one-command
1057
- // flow permanently on exactly the first-time-operator case it exists to
1058
- // serve. A successful bring-up IS the runtime fact.
1059
- return createRunAndRequestLanes({ deps, epicKey, options: parsed.options, access: accessResult.access }, {
1496
+ // Readiness is NOT re-read as a gate here. A successful bring-up IS the
1497
+ // runtime fact: the launch already waited for this plane's own reconciler
1498
+ // heartbeat. (`reconciler_live` now reads the deployment heartbeat, but the
1499
+ // server's repository-progress diagnostics — `epic_runs.last_tick_at`,
1500
+ // `executor_jobs` — stay empty on a first run, and nothing here waits on
1501
+ // them.)
1502
+ return await createRunAndRequestLanes(ctx, {
1060
1503
  origin: "launched",
1061
1504
  repoRoot,
1062
1505
  planeId: controlPlane.planeId,
@@ -1078,11 +1521,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1078
1521
  deps.errorLog(refusal);
1079
1522
  return 1;
1080
1523
  }
1081
- const handoff = renderConductorHandoff(selection, epicKey);
1524
+ // BAPI-1285 — reached only after discovery came back empty or ambiguous and no
1525
+ // live run exists. Nothing was created, so this is a stop, not a success.
1526
+ const handoff = renderConductorHandoff(selection, epicKey, { profile: launchProfile, options: parsed.options }, discoveryStop);
1082
1527
  const text = handoff.lines.join("\n");
1083
1528
  assertSingleConductorInvocation(text);
1084
- deps.log(text);
1085
- return 0;
1529
+ deps.errorLog(text);
1530
+ return 1;
1086
1531
  }
1087
1532
  catch (err) {
1088
1533
  // Last resort. Sanitized, on stderr, converted to an exit code — a
@@ -1114,6 +1559,23 @@ async function surfaceContainmentAdvisory(deps, planFile) {
1114
1559
  // Fail open — a containment advisory is never allowed to block dispatch.
1115
1560
  }
1116
1561
  }
1562
+ /**
1563
+ * BAPI-1285 — the one remedy sentence an unknown-readiness escalation ends with.
1564
+ * A constant so the preview's `readiness.remedy` is the very line the real
1565
+ * refusal prints, not a second copy that can drift.
1566
+ */
1567
+ const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve conductor readiness.";
1568
+ /** The reason id the preview reports for an unknown readiness read. */
1569
+ const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
1570
+ /**
1571
+ * The remedy an operator-blocked refusal names: the requirement's own, else the
1572
+ * general install remedy. One function so the refusal, the cold-start refusal, and
1573
+ * the preview cannot disagree.
1574
+ */
1575
+ function operatorBlockedRemedy(classification) {
1576
+ return (classification.remedy ??
1577
+ "Resolve it with `install-bridge conductor`, then run drive-epic again.");
1578
+ }
1117
1579
  /**
1118
1580
  * The escalation branch. Prints NO conductor invocation at all — naming a
1119
1581
  * fallback here is the dual-authority hazard, and an operator who does not know
@@ -1124,8 +1586,8 @@ function escalate(deps, epicKey, reason) {
1124
1586
  `Cannot determine which conductor owns ${epicKey}: ${reason}`,
1125
1587
  "",
1126
1588
  "This is not a not-ready result — it is an unknown one, and starting the wrong",
1127
- "conductor on an epic wedges it permanently. Restore Bridge API access and run",
1128
- "drive-epic again, or ask an operator to resolve conductor readiness.",
1589
+ "conductor on an epic wedges it permanently.",
1590
+ ESCALATE_REMEDY,
1129
1591
  ];
1130
1592
  const text = lines.join("\n");
1131
1593
  assertSingleConductorInvocation(text);
@@ -1227,9 +1689,7 @@ async function coldStartPlane(ctx) {
1227
1689
  }
1228
1690
  const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1229
1691
  if (!controlPlane.ok) {
1230
- for (const line of controlPlane.lines)
1231
- deps.errorLog(line);
1232
- return 1;
1692
+ return reportControlPlaneLaunchFailure(ctx, controlPlane);
1233
1693
  }
1234
1694
  void controlPlane.lifetime.catch(() => undefined);
1235
1695
  // Read ONCE, for route selection only — the pre-launch read had no server to
@@ -1254,8 +1714,7 @@ async function coldStartPlane(ctx) {
1254
1714
  if (classification.kind === "operator_blocked") {
1255
1715
  return refuse(deps, [
1256
1716
  `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
1257
- classification.remedy ??
1258
- "Resolve it with `install-bridge conductor`, then run drive-epic again.",
1717
+ operatorBlockedRemedy(classification),
1259
1718
  ...LAUNCHED_PLANE_LEFT_RUNNING,
1260
1719
  ]);
1261
1720
  }
@@ -1287,7 +1746,7 @@ async function coldStartPlane(ctx) {
1287
1746
  */
1288
1747
  async function attachToPlane(ctx, manifest, readiness) {
1289
1748
  const { deps, epicKey } = ctx;
1290
- const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot);
1749
+ const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot, launchSelectionOf(ctx.options));
1291
1750
  if (!rooted.ok) {
1292
1751
  return refuse(deps, [
1293
1752
  `${epicKey}: cannot attach to the running plane — Bridge API credentials could not be ` +
@@ -1297,7 +1756,10 @@ async function attachToPlane(ctx, manifest, readiness) {
1297
1756
  ]);
1298
1757
  }
1299
1758
  const attachCtx = { ...ctx, access: rooted.access };
1300
- // R-7, decided BEFORE any create request.
1759
+ // R-7, decided BEFORE any create request. It binds the LANES only
1760
+ // (BAPI-1279): a plane whose lanes serve another run still has a reconciler
1761
+ // that can drive this one, and gets a dedicated executor for it.
1762
+ let dedicatedExecutorReason = null;
1301
1763
  if (manifest.epicRunId !== undefined) {
1302
1764
  let bound = null;
1303
1765
  try {
@@ -1306,28 +1768,33 @@ async function attachToPlane(ctx, manifest, readiness) {
1306
1768
  catch {
1307
1769
  bound = null;
1308
1770
  }
1309
- const why = bound === null
1310
- ? "its bound run could not be read"
1311
- : TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
1312
- ? `its bound run is ${bound.status}`
1313
- : bound.epicKey !== epicKey
1314
- ? `its bound run belongs to ${bound.epicKey}`
1315
- : null;
1316
- if (why !== null) {
1771
+ if (bound === null) {
1317
1772
  return refuse(deps, [
1318
1773
  `${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
1319
- `${manifest.epicRunId}, and ${why}.`,
1320
- "A plane serves exactly one epic run (one epic per plane). Finish or stop that run, wind the",
1321
- "plane down with `plane down`, and run drive-epic again.",
1774
+ `${manifest.epicRunId}, and its bound run could not be read, so it is unknown whether ` +
1775
+ "the plane is healthy.",
1776
+ "Restore Bridge API access, or inspect `plane status` and that run's own status, then run",
1777
+ "drive-epic again.",
1322
1778
  "",
1323
1779
  "Nothing was started.",
1324
1780
  ]);
1325
1781
  }
1782
+ if (isTerminalEpicRunStatus(bound.status)) {
1783
+ dedicatedExecutorReason = `its lanes' run ${manifest.epicRunId} is ${bound.status}`;
1784
+ }
1785
+ else if (bound.epicKey !== epicKey) {
1786
+ dedicatedExecutorReason = `its lanes serve run ${manifest.epicRunId} of ${bound.epicKey}`;
1787
+ }
1326
1788
  }
1327
1789
  const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
1328
1790
  if (admission !== null) {
1329
1791
  return refuse(deps, [...admission, "", "Nothing was started."]);
1330
1792
  }
1793
+ if (dedicatedExecutorReason !== null) {
1794
+ deps.log(`${epicKey}: attaching to this repository's running plane (${dedicatedExecutorReason}); ` +
1795
+ "its reconciler will drive this run too, and a dedicated executor will be started for it.");
1796
+ return createRunWithRunExecutor(attachCtx, readiness);
1797
+ }
1331
1798
  deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
1332
1799
  return createRunAndRequestLanes(attachCtx, {
1333
1800
  origin: "attached",
@@ -1411,7 +1878,8 @@ async function createRunAndRequestLanes(ctx, plane) {
1411
1878
  `${epicKey}: the run was created and approved, but executor lanes could not be`,
1412
1879
  `requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
1413
1880
  laneRequest.reason === "run-conflict"
1414
- ? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
1881
+ ? "This plane's lanes already serve another run. Run drive-epic again: it will attach " +
1882
+ "to this plane's reconciler and start a dedicated run-scoped executor for this run."
1415
1883
  : "Inspect it with `plane status`, and wind it down with `plane down`.",
1416
1884
  "The run itself is untouched and still committed server-side.",
1417
1885
  ].join("\n"));
@@ -1464,3 +1932,742 @@ async function createRunAndRequestLanes(ctx, plane) {
1464
1932
  ].join("\n"));
1465
1933
  return 0;
1466
1934
  }
1935
+ // ---------------------------------------------------------------------------
1936
+ // BAPI-1279 — every launch ensures an executor; remote targets touch no plane
1937
+ // ---------------------------------------------------------------------------
1938
+ /**
1939
+ * The refusals BAPI-1279 adds. Like {@link DRIVE_EPIC_PLANE_REFUSALS}, these
1940
+ * exist nowhere else; the bring-up facts keep refusing under their own names.
1941
+ */
1942
+ export const DRIVE_EPIC_LAUNCH_REFUSALS = [
1943
+ /** A remote target reports no live reconciler and no bring-up fact says why. */
1944
+ "remote-reconciler-not-live",
1945
+ /** A durable launch profile, but no fresh repo-wide executor is confirmed. */
1946
+ "durable-executor-not-live",
1947
+ /** The run-scoped executor could never be started from this checkout. */
1948
+ "executor-precondition",
1949
+ ];
1950
+ /** Said on every remote refusal, because operators expect a local fallback. */
1951
+ const REMOTE_NO_PLANE_LINE = "No local plane was started, because the target is not this machine's plane.";
1952
+ /**
1953
+ * `worker-not-present` against a remote target. The shared remediation offers
1954
+ * "bring up a local plane", which cannot help a deployment on another machine
1955
+ * and would contradict {@link REMOTE_NO_PLANE_LINE}.
1956
+ */
1957
+ function remoteWorkerNotPresentMessage(baseUrl) {
1958
+ return (`the Bridge API at ${baseUrl} reports no worker running its reconciler, so nothing would ` +
1959
+ "drive this run. Start that deployment's worker process — exactly one (for example one " +
1960
+ "Heroku `worker` dyno) — then run drive-epic again.");
1961
+ }
1962
+ /**
1963
+ * Is THIS checkout's plane the one listening on the plane endpoint, and able to
1964
+ * take a run? Returns its manifest, or `null`.
1965
+ *
1966
+ * Reads the manifest first and runs the plane preflight only when there is one:
1967
+ * with no manifest here there is nothing to attach to, and a green plane target
1968
+ * served by a sibling worktree's plane needs no preflight at all.
1969
+ */
1970
+ async function findAttachablePlane(ctx) {
1971
+ const repoRoot = ctx.deps.cwd();
1972
+ const manifestRead = await ctx.deps.readPlaneManifest(repoRoot);
1973
+ if (manifestRead.kind !== "valid")
1974
+ return null;
1975
+ const preflight = await ctx.deps.runPlanePreflight(repoRoot);
1976
+ const existingPlane = preflight.observations?.existingPlane;
1977
+ return existingPlane?.kind === "attachable" && preflight.observations?.serverPort === "connected"
1978
+ ? existingPlane.manifest
1979
+ : null;
1980
+ }
1981
+ /**
1982
+ * Runtime-only readiness against a target that is NOT this machine's plane.
1983
+ *
1984
+ * The missing reconciler belongs to that deployment, so nothing started here
1985
+ * could help. One named reason: the first bring-up refusal, whose message
1986
+ * already names its remedy, else `remote-reconciler-not-live`. No run is
1987
+ * created and nothing local is touched.
1988
+ */
1989
+ function refuseRemoteRuntime(ctx, readiness) {
1990
+ return refuse(ctx.deps, remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness).lines);
1991
+ }
1992
+ /** The runtime-only refusal for a target that is not this machine's plane. */
1993
+ function remoteRuntimeRefusal(epicKey, access, readiness) {
1994
+ const bringUp = readiness.bring_up ?? null;
1995
+ const reason = firstBringUpRefusal(bringUp);
1996
+ const reasonId = reason ?? "remote-reconciler-not-live";
1997
+ const remedy = reason !== null
1998
+ ? reason === "worker-not-present"
1999
+ ? remoteWorkerNotPresentMessage(access.baseUrl)
2000
+ : BRING_UP_REFUSAL_MESSAGES[reason]
2001
+ : `the Bridge API at ${access.baseUrl} reports no live reconciler, so nothing would drive ` +
2002
+ "this run. Check that its worker process is running, then run drive-epic again.";
2003
+ const lines = [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`];
2004
+ if (bringUp === null) {
2005
+ lines.push("This Bridge API version cannot report deployment liveness, so a launch against it cannot " +
2006
+ "be confirmed. Upgrade the Bridge API deploy.");
2007
+ }
2008
+ lines.push("", "No run was created.", REMOTE_NO_PLANE_LINE);
2009
+ return { reasonId, remedy, lines };
2010
+ }
2011
+ /**
2012
+ * BAPI-1285 — a durable launch profile with no confirmed fresh repo-wide
2013
+ * executor. `created` says what state the refusal leaves: before a create nothing
2014
+ * exists; on a live-run attach the run is simply untouched.
2015
+ */
2016
+ function durableCoverageRefusal(epicKey, access, profile, coverage, outcome) {
2017
+ const remedy = [
2018
+ "Start the durable host's executor with --repo-wide",
2019
+ " (`install conductor --execution-mode durable --executor-id <id> --repo-wide`),",
2020
+ "or launch without the durable profile.",
2021
+ ].join("\n");
2022
+ return {
2023
+ reasonId: "durable-executor-not-live",
2024
+ remedy,
2025
+ lines: [
2026
+ `${epicKey}: REFUSED (durable-executor-not-live) — launch profile '${profile.name}' selects ` +
2027
+ `durable execution, but a fresh repo-wide executor for ${access.repoName} could not be ` +
2028
+ "confirmed" +
2029
+ (coverage === undefined
2030
+ ? " (this Bridge API version does not report executor coverage)."
2031
+ : "."),
2032
+ remedy,
2033
+ "",
2034
+ `${outcome}, and no local executor was started.`,
2035
+ ],
2036
+ };
2037
+ }
2038
+ /** The pre-create refusal when no executor could ever be started from this checkout. */
2039
+ function executorPreconditionRefusal(epicKey, message, outcome) {
2040
+ return {
2041
+ reasonId: "executor-precondition",
2042
+ remedy: message,
2043
+ lines: [`${epicKey}: REFUSED (executor-precondition) — ${message}`, "", outcome],
2044
+ };
2045
+ }
2046
+ /** BAPI-1285 — `--plan-file` absent, and the checkout cannot say where plans live. */
2047
+ function docsRootRefusal(epicKey) {
2048
+ const remedy = "Run drive-epic from inside the target repository's Git checkout, set BAPI_DOCS_DIR to an " +
2049
+ "absolute path, or pass --plan-file.";
2050
+ return {
2051
+ reasonId: "docs-root-unresolvable",
2052
+ remedy,
2053
+ lines: [
2054
+ `${epicKey}: REFUSED (docs-root-unresolvable) — this directory is not inside a Git work tree ` +
2055
+ "and BAPI_DOCS_DIR is not an absolute path, so the directory holding epic plans could not " +
2056
+ "be located.",
2057
+ remedy,
2058
+ "",
2059
+ "Nothing was created and nothing was started.",
2060
+ ],
2061
+ };
2062
+ }
2063
+ /** BAPI-1285 — several active runs for one epic: it is wedged. */
2064
+ function liveRunAmbiguousRefusal(epicKey) {
2065
+ const remedy = "Abandon the duplicate run with `epic-implementer abandon-run --epic-run-id " +
2066
+ "<duplicate_epic_run_id>`, then run drive-epic again.";
2067
+ return {
2068
+ reasonId: "live-run-ambiguous",
2069
+ remedy,
2070
+ lines: [
2071
+ `${epicKey}: REFUSED (live-run-ambiguous) — this epic has more than one active run, so it is ` +
2072
+ "wedged and drive-epic cannot tell which run to drive.",
2073
+ remedy,
2074
+ "",
2075
+ "Nothing was created and nothing was started.",
2076
+ ],
2077
+ };
2078
+ }
2079
+ /** BAPI-1285 — the epic's run state could not be read, so nothing may be assumed. */
2080
+ function liveRunUnknownRefusal(epicKey, reason) {
2081
+ const remedy = "Restore Bridge API access, then run drive-epic again. Creating a run without knowing " +
2082
+ "whether one exists could mint a duplicate, which wedges the epic permanently.";
2083
+ return {
2084
+ reasonId: "live-run-unknown",
2085
+ remedy,
2086
+ lines: [
2087
+ `${epicKey}: REFUSED (live-run-unknown) — the epic's run state could not be read (${reason}).`,
2088
+ remedy,
2089
+ "",
2090
+ "Nothing was created and nothing was started.",
2091
+ ],
2092
+ };
2093
+ }
2094
+ /**
2095
+ * Create the run, then make sure an executor will claim its jobs — the path for
2096
+ * every launch that does not request a plane lane.
2097
+ *
2098
+ * - Durable launch profile: confirm a fresh REPO-WIDE executor covers the
2099
+ * repository BEFORE creating anything, and start nothing locally.
2100
+ * - Otherwise: resolve the checkout and build BEFORE creating anything, then
2101
+ * create the run, then start (or reuse) a run-scoped executor for it.
2102
+ *
2103
+ * The order is the whole design: every refusal that can happen before a run
2104
+ * exists does, so a refusal never leaves a run behind. After the run exists,
2105
+ * nothing here stops or retries it.
2106
+ */
2107
+ async function createRunWithRunExecutor(ctx, readiness) {
2108
+ const { deps, epicKey, access, profile } = ctx;
2109
+ if (profile !== null && profile.executionMode === "durable") {
2110
+ const coverage = readiness.executor.repo_wide_executor_live;
2111
+ if (coverage !== true) {
2112
+ return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
2113
+ }
2114
+ const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
2115
+ if (setup.exitCode === 0) {
2116
+ deps.log([
2117
+ "",
2118
+ `${epicKey}: run ${setup.epicRunId ?? "(id not reported)"} created on ${access.baseUrl}.`,
2119
+ "The durable host's repo-wide executor claims its jobs; no local executor was started.",
2120
+ ].join("\n"));
2121
+ }
2122
+ return setup.exitCode;
2123
+ }
2124
+ // PRE-CREATE: a launch that could never start an executor refuses here.
2125
+ const prepared = await deps.prepareRunExecutor(deps.cwd());
2126
+ if (!prepared.ok) {
2127
+ return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
2128
+ }
2129
+ const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
2130
+ if (setup.exitCode !== 0) {
2131
+ // setup-epic already said why. Nothing was started for a run that may not exist.
2132
+ deps.errorLog(`\n${epicKey}: no executor was started.`);
2133
+ return setup.exitCode;
2134
+ }
2135
+ if (setup.epicRunId === undefined) {
2136
+ // Exit zero with no run id: there is no authoritative identity to scope an
2137
+ // executor to, and guessing could attach it to a DIFFERENT run.
2138
+ return refuse(deps, [
2139
+ "",
2140
+ `${epicKey}: setup reported success but returned no epic run id, so no executor could be`,
2141
+ "attached to the run. NO EXECUTOR WAS STARTED. Inspect this repository's epic runs",
2142
+ "server-side before retrying; retrying blindly could create a second run.",
2143
+ ]);
2144
+ }
2145
+ // POST-CREATE: start or reuse exactly one executor scoped to this run.
2146
+ const ensured = await deps.ensureRunExecutor({
2147
+ prepared: prepared.prepared,
2148
+ repoName: access.repoName,
2149
+ epicRunId: setup.epicRunId,
2150
+ access,
2151
+ profile,
2152
+ });
2153
+ return reportExecutorOutcome(ctx, ensured, setup.epicRunId, "created");
2154
+ }
2155
+ /**
2156
+ * Print an executor outcome — started, reused, or failed — and return the exit
2157
+ * code. Shared by the create path and the live-run attach path (BAPI-1285) so the
2158
+ * two print identical text rather than two copies that drift. Only the framing
2159
+ * differs: whether the run was just CREATED or was already live.
2160
+ *
2161
+ * The readiness, durable, and plane gates stay with the callers; this is only the
2162
+ * report. A failure never stops or changes the run.
2163
+ */
2164
+ function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
2165
+ const { deps, epicKey, access } = ctx;
2166
+ if (!ensured.ok) {
2167
+ return refuse(deps, [
2168
+ "",
2169
+ framing === "created"
2170
+ ? `${epicKey}: Run created but executor unavailable — run ${epicRunId} on ${access.baseUrl}.`
2171
+ : `${epicKey}: Run ${epicRunId} executor unavailable — the run is already live on ${access.baseUrl}.`,
2172
+ `No executor heartbeat was confirmed (${ensured.reason}): ${ensured.message}.`,
2173
+ ` Log: ${ensured.logPath}`,
2174
+ ...(ensured.executorMayBeRunning
2175
+ ? [
2176
+ "An executor for this run may still be running, so do not start a second one beside",
2177
+ "it. Check the log and the process first; only once none is running, start it by hand",
2178
+ "from this checkout:",
2179
+ ]
2180
+ : ["Start it by hand from this checkout:"]),
2181
+ ` ${ensured.manualCommand}`,
2182
+ "The run was NOT stopped. It stays server-side and recoverable, and resumes as soon as an",
2183
+ "executor claims its jobs.",
2184
+ ]);
2185
+ }
2186
+ const started = ensured.outcome === "started"
2187
+ ? `A run-scoped executor was started for it in the background (pid ${ensured.pid}).`
2188
+ : `A run-scoped executor for it was already running (pid ${ensured.pid}); no second one was started.`;
2189
+ deps.log([
2190
+ "",
2191
+ framing === "created"
2192
+ ? `${epicKey}: run ${epicRunId} is created on ${access.baseUrl}.`
2193
+ : `${epicKey}: run ${epicRunId} is already live on ${access.baseUrl}; nothing was created.`,
2194
+ started,
2195
+ "It claims only this run's jobs, and exits on its own once the run is done or abandoned.",
2196
+ ` Log: ${ensured.logPath}`,
2197
+ ` Record: ${ensured.recordPath}`,
2198
+ "It runs on THIS machine: sleep is held off while it runs, but a closed lid, a logout, or a",
2199
+ "shutdown stops it. The run stays server-side either way; restart the executor, or use a",
2200
+ "durable launch profile for long runs.",
2201
+ ].join("\n"));
2202
+ return 0;
2203
+ }
2204
+ /**
2205
+ * Is this checkout's plane serving the live run? True only when BOTH binding
2206
+ * facts agree: the plane manifest's `epicRunId` equals the live run's id, and the
2207
+ * bound run reads back live and of this epic. Anything unreadable or mismatched is
2208
+ * false, and the caller takes the ordinary run-scoped executor path — a plane is
2209
+ * never presumed to be covering a run it cannot be shown to cover.
2210
+ */
2211
+ async function planeServesLiveRun(ctx, live) {
2212
+ try {
2213
+ const manifest = await findAttachablePlane(ctx);
2214
+ if (manifest === null || manifest.epicRunId !== live.epicRunId)
2215
+ return false;
2216
+ const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
2217
+ return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
2218
+ }
2219
+ catch {
2220
+ return false;
2221
+ }
2222
+ }
2223
+ /**
2224
+ * BAPI-1285 — a live run and no usable plan: make sure the run has an executor.
2225
+ *
2226
+ * It creates nothing and never calls `setup-epic`, which hard-requires a plan
2227
+ * file. Readiness gates it exactly as it gates a create (only green proceeds),
2228
+ * because starting an executor for a run nothing can drive is not attaching to it.
2229
+ *
2230
+ * - durable profile: confirm a fresh repo-wide executor and start nothing local;
2231
+ * - this checkout's plane bound to THIS run: its lanes serve it, start nothing;
2232
+ * - otherwise: prepare, then ensure a run-scoped executor for the LIVE run's id.
2233
+ */
2234
+ async function attachToLiveRun(ctx, readiness, live) {
2235
+ const { deps, epicKey, access, profile } = ctx;
2236
+ const classification = classifyV2Readiness(readiness, {
2237
+ attended: ctx.options.attended === true,
2238
+ intoBase: ctx.options.intoBase === true,
2239
+ hasPolicyFile: ctx.options.policyFile !== undefined,
2240
+ });
2241
+ if (classification.kind === "operator_blocked") {
2242
+ return refuse(deps, [
2243
+ `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
2244
+ "",
2245
+ "This is not something starting the runtime can fix, so nothing was started.",
2246
+ operatorBlockedRemedy(classification),
2247
+ ]);
2248
+ }
2249
+ const planeTarget = planeEndpointForBaseUrl(deps, access.baseUrl) !== null;
2250
+ if (classification.kind === "runtime_only") {
2251
+ if (!planeTarget)
2252
+ return refuseRemoteRuntime(ctx, readiness);
2253
+ return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
2254
+ }
2255
+ deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
2256
+ "found, so nothing will be created; making sure it has an executor.");
2257
+ if (profile !== null && profile.executionMode === "durable") {
2258
+ const coverage = readiness.executor.repo_wide_executor_live;
2259
+ if (coverage !== true) {
2260
+ return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
2261
+ }
2262
+ deps.log([
2263
+ "",
2264
+ `${epicKey}: run ${live.epicRunId} is served by the durable host's confirmed repo-wide executor.`,
2265
+ "No local executor was started.",
2266
+ ].join("\n"));
2267
+ return 0;
2268
+ }
2269
+ if (planeTarget && (await planeServesLiveRun(ctx, live))) {
2270
+ deps.log([
2271
+ "",
2272
+ `${epicKey}: this checkout's plane is bound to run ${live.epicRunId}; its executor lanes serve it.`,
2273
+ "No run-scoped executor was started beside them.",
2274
+ ].join("\n"));
2275
+ return 0;
2276
+ }
2277
+ const prepared = await deps.prepareRunExecutor(deps.cwd());
2278
+ if (!prepared.ok) {
2279
+ return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was changed.").lines);
2280
+ }
2281
+ const ensured = await deps.ensureRunExecutor({
2282
+ prepared: prepared.prepared,
2283
+ repoName: access.repoName,
2284
+ epicRunId: live.epicRunId,
2285
+ access,
2286
+ profile,
2287
+ });
2288
+ return reportExecutorOutcome(ctx, ensured, live.epicRunId, "attached");
2289
+ }
2290
+ /**
2291
+ * BAPI-1285 — a live run, a local plane target, and a reconciler that is not
2292
+ * live. Without a plan drive-epic cannot compose the bring-up (that happens only
2293
+ * while creating a run), so this is one named refusal rather than an executor for
2294
+ * a run nothing is driving.
2295
+ */
2296
+ function localReconcilerRefusal(epicKey, readiness) {
2297
+ const reason = firstBringUpRefusal(readiness.bring_up ?? null);
2298
+ const reasonId = reason ?? "local-reconciler-not-live";
2299
+ const remedy = reason !== null
2300
+ ? BRING_UP_REFUSAL_MESSAGES[reason]
2301
+ : "the local conductor plane reports no live reconciler, so nothing would drive this run. " +
2302
+ "Start it with `plane up`, or pass --plan-file so drive-epic can bring the runtime up.";
2303
+ return {
2304
+ reasonId,
2305
+ remedy,
2306
+ lines: [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`, "", "No run was changed."],
2307
+ };
2308
+ }
2309
+ /**
2310
+ * Render a failed control-plane launch.
2311
+ *
2312
+ * The launch already printed its own reason. The one case this adds to is the
2313
+ * pre-worker guard finding another reconciler live (BAPI-1279): name it as
2314
+ * `second-reconciler-detected` and give the attach remedy. "Nothing was left
2315
+ * running" is said only after a CONFIRMED rollback.
2316
+ */
2317
+ function reportControlPlaneLaunchFailure(ctx, controlPlane) {
2318
+ const { deps, epicKey } = ctx;
2319
+ for (const line of controlPlane.lines)
2320
+ deps.errorLog(line);
2321
+ if (controlPlane.foreignReconciler === undefined)
2322
+ return 1;
2323
+ // The live plane is on ANOTHER port — a cold start happens only when this
2324
+ // plane's own port is free — so the port is named by that plane, not here.
2325
+ return refuse(deps, [
2326
+ "",
2327
+ `${epicKey}: REFUSED (second-reconciler-detected) — ` +
2328
+ BRING_UP_REFUSAL_MESSAGES["second-reconciler-detected"],
2329
+ controlPlane.foreignReconciler.rolledBack
2330
+ ? "The server this invocation started was rolled back; nothing was left running."
2331
+ : "The rollback of the server this invocation started could NOT be confirmed. Check " +
2332
+ "`plane status` and stop anything it still lists.",
2333
+ "Concurrent planes on one database are not supported. To run this epic next to the live one,",
2334
+ "point this launch at the plane that owns that reconciler (pass --base-url",
2335
+ "http://127.0.0.1:<port>, with the port that plane's `plane status` reports, and change or",
2336
+ "unset BAPI_BASE_URL if it names a different endpoint). drive-epic will create the run",
2337
+ "against that plane and start a run-scoped executor in this checkout.",
2338
+ "",
2339
+ "No run was created.",
2340
+ ]);
2341
+ }
2342
+ /** The readiness reason a preview reports for a generic not-ready engine. */
2343
+ 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.";
2345
+ /**
2346
+ * Read exactly one well-shaped setup-epic dry-run JSON document out of captured
2347
+ * `log` output. Anything else — none, several, or one with the wrong shape — is
2348
+ * `null`. Human text that is not JSON is ignored, never parsed for meaning.
2349
+ */
2350
+ function readSetupPreviewPayload(stdout) {
2351
+ const documents = [];
2352
+ for (const message of stdout) {
2353
+ let value;
2354
+ try {
2355
+ value = JSON.parse(message);
2356
+ }
2357
+ catch {
2358
+ continue;
2359
+ }
2360
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2361
+ documents.push(value);
2362
+ }
2363
+ }
2364
+ if (documents.length !== 1)
2365
+ return null;
2366
+ const doc = documents[0];
2367
+ if (doc.dry_run !== true || typeof doc.server_validated !== "boolean")
2368
+ return null;
2369
+ const existing = doc.existing_run_id;
2370
+ if (existing !== null && existing !== undefined && typeof existing !== "string")
2371
+ return null;
2372
+ const branch = doc.feature_branch;
2373
+ if (branch !== undefined && typeof branch !== "string")
2374
+ return null;
2375
+ return {
2376
+ existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
2377
+ featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
2378
+ serverValidated: doc.server_validated,
2379
+ };
2380
+ }
2381
+ /** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
2382
+ async function readPlanChildCount(deps, planPath) {
2383
+ try {
2384
+ const parsed = JSON.parse(await deps.readPlanDag(planPath));
2385
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
2386
+ return null;
2387
+ const nodes = parsed.nodes;
2388
+ return Array.isArray(nodes) ? nodes.length : null;
2389
+ }
2390
+ catch {
2391
+ return null;
2392
+ }
2393
+ }
2394
+ /**
2395
+ * Run the read-only preview. Returns `0` for a `create` or `attach` decision and
2396
+ * `1` for `refuse` or `stop`. Never throws: an unexpected failure becomes one
2397
+ * named refusal in the same document.
2398
+ */
2399
+ async function runDriveEpicPreview(deps, options) {
2400
+ const { epicKey } = options;
2401
+ const posture = options.attended === true || options.intoBase === true ? "attended" : "unattended";
2402
+ const preview = {
2403
+ epic_key: epicKey,
2404
+ target: { base_url: null, kind: null },
2405
+ readiness: { kind: "unknown" },
2406
+ live_run: null,
2407
+ plan: { status: "not_found" },
2408
+ summary: {
2409
+ child_count: null,
2410
+ branch: null,
2411
+ posture,
2412
+ auto_merge_scope: null,
2413
+ escalation_channel: null,
2414
+ server_validation: "not_run",
2415
+ },
2416
+ decision: "refuse",
2417
+ refusal: null,
2418
+ };
2419
+ /** A refusal carried by the readiness block (and mirrored where the ticket needs it). */
2420
+ const refuseWithReadiness = (kind, named, alsoTopLevel = false) => {
2421
+ preview.readiness = { kind, reason_id: named.reasonId, remedy: named.remedy };
2422
+ if (alsoTopLevel)
2423
+ preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
2424
+ preview.decision = "refuse";
2425
+ };
2426
+ /** A refusal that is not about readiness: only the top-level block carries it. */
2427
+ const refuseTopLevel = (named) => {
2428
+ preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
2429
+ preview.decision = "refuse";
2430
+ };
2431
+ try {
2432
+ await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel);
2433
+ }
2434
+ catch {
2435
+ // Sanitized on purpose: nothing from the thrown value reaches the document.
2436
+ preview.summary.server_validation =
2437
+ preview.summary.server_validation === "passed" ? "passed" : "not_run";
2438
+ refuseTopLevel({
2439
+ reasonId: "preview-failed",
2440
+ remedy: "The preview could not complete. Run it again; nothing was created or started.",
2441
+ });
2442
+ }
2443
+ emitPreview(deps, options, preview);
2444
+ return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
2445
+ }
2446
+ /** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
2447
+ async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
2448
+ const { epicKey } = options;
2449
+ const unknownReadiness = () => refuseWithReadiness("unknown", {
2450
+ reasonId: READINESS_UNKNOWN_REASON_ID,
2451
+ remedy: ESCALATE_REMEDY,
2452
+ });
2453
+ // --- Target: resolved, never adopted. -------------------------------------
2454
+ const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
2455
+ if (!accessResult.ok)
2456
+ return unknownReadiness();
2457
+ const access = accessResult.access;
2458
+ 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 };
2462
+ // --- Readiness. -----------------------------------------------------------
2463
+ let readiness;
2464
+ try {
2465
+ readiness = await deps.readReadiness(access);
2466
+ }
2467
+ catch {
2468
+ return unknownReadiness();
2469
+ }
2470
+ const classification = classifyV2Readiness(readiness, {
2471
+ attended: options.attended === true,
2472
+ intoBase: options.intoBase === true,
2473
+ hasPolicyFile: options.policyFile !== undefined,
2474
+ });
2475
+ preview.readiness = { kind: classification.kind };
2476
+ 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
+ // --- 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
+ if (live.kind === "live") {
2510
+ preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
2511
+ }
2512
+ // --- No plan: attach to a live run, or stop. --------------------------------
2513
+ if (planPath === undefined) {
2514
+ if (live.kind !== "live") {
2515
+ if (selection.kind === "not_ready") {
2516
+ return refuseWithReadiness(classification.kind, {
2517
+ reasonId: ENGINE_NOT_READY_REASON_ID,
2518
+ remedy: ENGINE_NOT_READY_REMEDY,
2519
+ });
2520
+ }
2521
+ preview.decision = "stop";
2522
+ return;
2523
+ }
2524
+ const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "attach");
2525
+ if (blocked !== null)
2526
+ return refuseWithReadiness(classification.kind, blocked);
2527
+ const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed");
2528
+ if (facts !== null)
2529
+ return refuseWithReadiness(classification.kind, facts, true);
2530
+ preview.decision = "attach";
2531
+ return;
2532
+ }
2533
+ // --- A plan is available: the create path's gates, then setup's own dry run. --
2534
+ const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "create");
2535
+ if (blocked !== null)
2536
+ return refuseWithReadiness(classification.kind, blocked);
2537
+ const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
2538
+ if (facts !== null)
2539
+ return refuseWithReadiness(classification.kind, facts, true);
2540
+ // Escalation channel, decided with setup-epic's own exported policy helpers. A
2541
+ // policy that cannot be read is one fixed refusal; its path, contents, and any
2542
+ // webhook URL never leave this function.
2543
+ 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
+ }
2560
+ }
2561
+ preview.summary.child_count = await readPlanChildCount(deps, planPath);
2562
+ const setup = await deps.previewSetupEpic({
2563
+ argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), "--dry-run", "--json"],
2564
+ baseUrl: access.baseUrl,
2565
+ });
2566
+ if (setup.exitCode !== 0) {
2567
+ preview.summary.server_validation = "failed";
2568
+ return refuseTopLevel({
2569
+ reasonId: "setup-preview-failed",
2570
+ remedy: "The plan did not pass validation, or the epic could not be prepared. Fix the plan or " +
2571
+ "the condition it reports, then run the preview again. Nothing was created.",
2572
+ });
2573
+ }
2574
+ const payload = readSetupPreviewPayload(setup.stdout);
2575
+ if (payload === null) {
2576
+ preview.summary.server_validation = "not_run";
2577
+ return refuseTopLevel({
2578
+ reasonId: "setup-preview-malformed",
2579
+ remedy: "Plan validation returned no usable result, so the launch could not be predicted. Run " +
2580
+ "the preview again. Nothing was created.",
2581
+ });
2582
+ }
2583
+ preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
2584
+ preview.summary.branch = payload.featureBranch;
2585
+ preview.summary.auto_merge_scope =
2586
+ preview.summary.posture === "unattended" ? payload.featureBranch : null;
2587
+ // The bootstrap reusing a run IS an attach: it reports the run's id.
2588
+ if (payload.existingRunId !== null) {
2589
+ preview.live_run = {
2590
+ epic_run_id: payload.existingRunId,
2591
+ status: preview.live_run?.epic_run_id === payload.existingRunId ? preview.live_run.status : null,
2592
+ };
2593
+ preview.decision = "attach";
2594
+ return;
2595
+ }
2596
+ preview.decision = live.kind === "live" ? "attach" : "create";
2597
+ }
2598
+ /**
2599
+ * The readiness refusal a real launch would print, as a reason id and remedy, or
2600
+ * `null` when readiness lets the launch proceed. Built from the SAME functions the
2601
+ * real refusals use, so the strings agree byte for byte.
2602
+ */
2603
+ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, path) {
2604
+ if (classification.kind === "operator_blocked") {
2605
+ return { reasonId: classification.id, remedy: operatorBlockedRemedy(classification) };
2606
+ }
2607
+ if (classification.kind === "runtime_only") {
2608
+ if (!planeTarget)
2609
+ return remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness);
2610
+ // A local plane with only the reconciler missing: a real launch WITH a plan
2611
+ // brings the runtime up. Without one (attach) it is refused by name.
2612
+ if (path === "attach")
2613
+ return localReconcilerRefusal(ctx.epicKey, readiness);
2614
+ }
2615
+ return null;
2616
+ }
2617
+ /**
2618
+ * The executor-side refusals a real launch would hit, decided read-only: the
2619
+ * durable profile's coverage check, else whether an executor could be started
2620
+ * from this checkout at all. Returns `null` when neither refuses, including when
2621
+ * this checkout's plane already serves the live run and needs no executor.
2622
+ */
2623
+ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
2624
+ const { deps, epicKey, access, profile } = ctx;
2625
+ if (profile !== null && profile.executionMode === "durable") {
2626
+ const coverage = readiness.executor.repo_wide_executor_live;
2627
+ return coverage === true
2628
+ ? null
2629
+ : durableCoverageRefusal(epicKey, access, profile, coverage, outcome);
2630
+ }
2631
+ if (planeTarget) {
2632
+ if (live !== null) {
2633
+ if (await planeServesLiveRun(ctx, live))
2634
+ return null;
2635
+ }
2636
+ else if ((await findAttachablePlane(ctx).catch(() => null)) !== null) {
2637
+ return null;
2638
+ }
2639
+ }
2640
+ const inspected = await deps.inspectRunExecutorPrecondition(deps.cwd());
2641
+ return inspected.ok
2642
+ ? null
2643
+ : executorPreconditionRefusal(epicKey, inspected.message, outcome === "No run was created" ? "No run was created." : "No run was changed.");
2644
+ }
2645
+ /** Print the preview: one JSON line with `--json`, short readable lines otherwise. */
2646
+ function emitPreview(deps, options, preview) {
2647
+ if (options.json === true) {
2648
+ deps.log(JSON.stringify(preview));
2649
+ return;
2650
+ }
2651
+ const lines = [
2652
+ `${preview.epic_key}: launch preview (read-only; nothing was created or started)`,
2653
+ ` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
2654
+ ` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
2655
+ ` Live run: ${preview.live_run
2656
+ ? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
2657
+ : "none"}`,
2658
+ ` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
2659
+ ];
2660
+ for (const candidate of preview.plan.candidates ?? [])
2661
+ lines.push(` candidate: ${candidate}`);
2662
+ for (const skipped of preview.plan.skipped ?? []) {
2663
+ lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
2664
+ }
2665
+ 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)
2668
+ lines.push(` Remedy: ${preview.readiness.remedy}`);
2669
+ if (preview.refusal) {
2670
+ lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
2671
+ }
2672
+ deps.log(lines.join("\n"));
2673
+ }