@bridge_gpt/mcp-server 0.2.62 → 0.2.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +47 -8
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. package/package.json +2 -1
@@ -53,13 +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, resolveEpicLaunchBridgeApiAccess, 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
59
  import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, renderEpicLaunchCommand, shellQuote, } from "./epic-launch-profile.js";
60
60
  import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
61
61
  import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
62
- import { 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";
63
65
  import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
64
66
  import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
65
67
  import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
@@ -68,11 +70,11 @@ import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
68
70
  import { resolveMemberInstanceId } from "./plane/supervisor.js";
69
71
  import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
70
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";
71
75
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
72
76
  // the printed invocation interpolates it rather than repeating it.
73
77
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
74
- /** Jira epic key shape. Matches the epic-key positional rule exactly. */
75
- const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
76
78
  /** The conductor paths. Derived types below come from this constant. */
77
79
  export const DRIVE_EPIC_CONDUCTORS = ["v2"];
78
80
  /**
@@ -112,11 +114,22 @@ export const V2_READINESS_REQUIREMENTS = [
112
114
  {
113
115
  id: "reconciler_live",
114
116
  describe: "a reconciler that is ticking and not stale",
115
- 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,
116
126
  },
117
127
  {
118
128
  id: "executor_live",
119
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,
120
133
  satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
121
134
  },
122
135
  // BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
@@ -140,17 +153,22 @@ export const V2_READINESS_REQUIREMENTS = [
140
153
  ? "This Bridge API deploy does not report a repository's unattended " +
141
154
  "prerequisites at all, so none of them could be confirmed. Upgrade the " +
142
155
  "deploy, or pass --attended."
143
- : "Set `unattended_epic_implementer_allowed` for this repository " +
144
- "(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.",
145
159
  satisfied: (r) => r.unattended?.conductor_allowed === true,
146
160
  },
147
161
  {
148
162
  id: "unattended_notify_default",
149
163
  describe: "a verified notify webhook default for escalation",
150
164
  unattendedOnly: true,
151
- 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. " +
152
167
  "notify.local_sink does not satisfy this.",
153
- 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,
154
172
  },
155
173
  {
156
174
  id: "repository_readiness",
@@ -190,7 +208,10 @@ export const V2_READINESS_REQUIREMENTS = [
190
208
  /**
191
209
  * The readiness facts a `plane up` can actually repair (BAPI-1054).
192
210
  *
193
- * 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 —
194
215
  * `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
195
216
  * `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
196
217
  * stored server-side state, credentials, or repository configuration, and no
@@ -203,7 +224,7 @@ export const V2_READINESS_REQUIREMENTS = [
203
224
  * running plane behind and change nothing — which is the precise failure the
204
225
  * classification exists to prevent.
205
226
  */
206
- export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
227
+ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
207
228
  /**
208
229
  * Classify the SERVER's readiness report into the composition's three cases.
209
230
  *
@@ -243,9 +264,11 @@ export function classifyV2Readiness(readiness, options = {}) {
243
264
  // to say about — the same reasoning `--attended` rests on, reached by a
244
265
  // different route. See `composeDefaultSetupEpicPolicy`.
245
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);
246
269
  const applicable = optedOut
247
- ? deferred.filter((req) => req.unattendedOnly !== true)
248
- : deferred;
270
+ ? gating.filter((req) => req.unattendedOnly !== true)
271
+ : gating;
249
272
  const unmet = applicable.filter((req) => !req.satisfied(readiness));
250
273
  if (unmet.length === 0)
251
274
  return { kind: "green" };
@@ -284,18 +307,34 @@ export function getDriveEpicUsage() {
284
307
  "UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
285
308
  "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
286
309
  "repository base branch stays human-gated. Pass --attended to opt out. An",
287
- "unattended run has two one-time repository prerequisites (consent, and a",
288
- "verified notify webhook default) that readiness lists with their state and that",
289
- "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.",
290
328
  "",
291
329
  "One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
292
- "starts the conductor runtime for you when readiness is missing ONLY the",
293
- "reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
294
- "reconciler), creates and approves the run, then starts executor lanes SCOPED to",
295
- "that run, and starts the dead-man observer last — one invocation, no manual",
296
- "`plane up` first. A report missing supervisor setup, supervisor configuration,",
297
- "GitHub App credentials, or an unattended prerequisite is NOT started: those are",
298
- "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.",
299
338
  "",
300
339
  "The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
301
340
  `local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
@@ -307,18 +346,42 @@ export function getDriveEpicUsage() {
307
346
  " - refusal: anything else holds the port (another listener, a manifest for a",
308
347
  " different repository, an unreadable manifest, a plane that cannot take a",
309
348
  " run) — one named reason, and nothing is started.",
310
- "One epic per plane: a plane already bound to a different or finished run is",
311
- "refused as plane-already-bound. Executor lanes are always scoped to the one",
312
- "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.",
313
374
  "",
314
375
  "Preconditions are validated, never repaired: the current directory must be a Git",
315
- "work tree. No worktree is created for you and no replacement port is chosen for",
316
- "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.",
317
379
  "",
318
380
  "Options:",
319
- " --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
320
- " selected, drive-epic runs that bootstrap directly",
321
- " 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.",
322
385
  " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
323
386
  " --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
324
387
  " verbatim, never interpreted here.",
@@ -339,13 +402,27 @@ export function getDriveEpicUsage() {
339
402
  " --launch-profile <name> Select the host-local launch profile guided setup",
340
403
  " approved (`install conductor --launch-profile <name>`):",
341
404
  " target, repository, mode, runtime, and policy and",
342
- " credential references. The command setup prints",
343
- " already selects it and runs in a fresh shell.",
405
+ " credential references. Useful for a local or other",
406
+ " non-production server and for durable hosts.",
344
407
  " --base-url <url> Explicit Bridge API target. With a profile it must",
345
408
  " agree with it — an override never rebinds a profile.",
346
- "Without either, BAPI_BASE_URL is used; with none of the three, drive-epic",
347
- "refuses. Nothing defaults to production — select it deliberately. A stored",
348
- "credential is sent only to the endpoint setup approved it for.",
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.",
349
426
  "",
350
427
  " -h, --help Show this help",
351
428
  ].join("\n");
@@ -390,8 +467,19 @@ export function parseDriveEpicArgs(argv) {
390
467
  return trimmed;
391
468
  };
392
469
  let attended = false;
470
+ // BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
471
+ let dryRun = false;
472
+ let json = false;
393
473
  for (let i = 0; i < argv.length; i++) {
394
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
+ }
395
483
  if (arg === "--into-base") {
396
484
  intoBase = true;
397
485
  continue;
@@ -473,6 +561,15 @@ export function parseDriveEpicArgs(argv) {
473
561
  }
474
562
  positionals.push(arg);
475
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
+ }
476
573
  // BAPI-1009: refused HERE — before access resolution, before the readiness
477
574
  // request, and before any delegation — with the same corrective wording
478
575
  // setup-epic uses, so the two entry points explain one mistake one way.
@@ -530,6 +627,8 @@ export function parseDriveEpicArgs(argv) {
530
627
  ...(replacePolicy ? { replacePolicy: true } : {}),
531
628
  ...(launchProfile ? { launchProfile } : {}),
532
629
  ...(baseUrl ? { baseUrl } : {}),
630
+ ...(dryRun ? { dryRun: true } : {}),
631
+ ...(json ? { json: true } : {}),
533
632
  };
534
633
  return { status: "ok", options };
535
634
  }
@@ -556,7 +655,7 @@ export function parseDriveEpicArgs(argv) {
556
655
  * about to be created.
557
656
  */
558
657
  export function selectConductor(readiness) {
559
- 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));
560
659
  if (missing.length === 0) {
561
660
  return {
562
661
  kind: "selected",
@@ -598,18 +697,24 @@ export function buildSetupEpicArgv(options) {
598
697
  ...(replacePolicy ? ["--replace-policy"] : []),
599
698
  // BAPI-1260 — the SAME launch selection, so setup-epic resolves the same
600
699
  // target and bound credential drive-epic just did. Appended only when
601
- // supplied, so every pre-existing argv stays byte-identical.
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.
602
703
  ...(launchProfile ? ["--launch-profile", launchProfile] : []),
603
704
  ...(baseUrl ? ["--base-url", baseUrl] : []),
604
705
  ];
605
706
  }
606
707
  /**
607
- * 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.
608
710
  *
609
- * Precondition: the caller has already ruled out delegation and a not-ready
610
- * result. The `v2` branch reads as "green, but no plan file was supplied", which
611
- * is only true once {@link runDriveEpicCli} has declined to delegate — so route
612
- * 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.
613
718
  */
614
719
  export function renderConductorHandoff(selection, epicKey,
615
720
  /**
@@ -619,7 +724,12 @@ export function renderConductorHandoff(selection, epicKey,
619
724
  * shell. The plan file is the one thing that cannot be filled in yet, and it
620
725
  * is the only placeholder left.
621
726
  */
622
- launch = { profile: null }) {
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.
731
+ */
732
+ discovery) {
623
733
  const invocation = launch.profile !== null
624
734
  ? renderEpicLaunchCommand(launch.profile, {
625
735
  epicKey,
@@ -630,25 +740,49 @@ launch = { profile: null }) {
630
740
  ...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
631
741
  }).replace(`${shellQuote("<path>")}`, "<path>")
632
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
+ : [];
633
758
  return {
634
759
  conductor: "v2",
635
760
  invocation,
636
761
  lines: [
637
- `${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`,
638
771
  "",
639
- "The engine path needs a plan DAG, and nothing derives one from an epic key:",
640
- "the dependency edges and per-ticket touched_files it carries do not exist",
641
- "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.",
642
774
  "",
643
- ` 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`,
644
776
  " sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
645
777
  " If the epic's tickets already exist, run emit-epic-implementer-bundle",
646
778
  " finalize instead — it resolves the placeholder keys in an existing",
647
- " sidecar and attaches touched_files.",
779
+ " sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
780
+ " plan is found next time.",
648
781
  "",
649
- ` 2. ${invocation}`,
782
+ ` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
783
+ ` or from a shell: ${invocation}`,
650
784
  "",
651
- `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`,
652
786
  "something you invoke directly.",
653
787
  ],
654
788
  };
@@ -884,8 +1018,6 @@ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
884
1018
  memberEnv.DYNO = env.DYNO;
885
1019
  return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
886
1020
  }
887
- /** Terminal `epic_runs.status` values; every other status is a live run. */
888
- const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
889
1021
  /** The BAPI-1260 launch selection carried by parsed options. */
890
1022
  function launchSelectionOf(options) {
891
1023
  return {
@@ -893,11 +1025,80 @@ function launchSelectionOf(options) {
893
1025
  ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
894
1026
  };
895
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
+ }
896
1096
  export function createDefaultDriveEpicDeps() {
897
1097
  return {
898
1098
  resolveAccess: async (repo, cwd, launch) => {
899
- // BAPI-1260: the target-first launch contract. The generic resolver's
900
- // production default does not apply to an epic 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.
901
1102
  const result = await resolveEpicLaunchBridgeApiAccess(launch ?? {}, {
902
1103
  ...(repo ? { repoName: repo } : {}),
903
1104
  ...(cwd ? { cwd } : {}),
@@ -955,6 +1156,39 @@ export function createDefaultDriveEpicDeps() {
955
1156
  return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
956
1157
  },
957
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()),
958
1192
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
959
1193
  // construction, so nothing has claimed stdout for the protocol transport.
960
1194
  log: (message) => console.log(message),
@@ -982,18 +1216,73 @@ export async function runDriveEpicCli(argv, overrides = {}) {
982
1216
  }
983
1217
  const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
984
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
+ }
985
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).
986
1228
  // --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
987
1229
  // BAPI-1260: the TARGET is decided first — from --launch-profile,
988
- // --base-url, and BAPI_BASE_URL — and only then is a credential bound to
989
- // that target read. A missing or conflicting target refuses here, before
990
- // any authenticated request; nothing defaults to production.
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.
991
1235
  const accessResult = await deps.resolveAccess(repo, undefined, launch);
992
1236
  if (!accessResult.ok) {
993
1237
  return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
994
1238
  }
995
1239
  const launchProfile = accessResult.profile ?? null;
996
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
+ };
997
1286
  // --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
998
1287
  let readiness;
999
1288
  try {
@@ -1006,7 +1295,10 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1006
1295
  // Everything else — a non-plane base URL, an HTTP or auth or shape error, a
1007
1296
  // timeout, any other probe result — keeps today's escalation byte for byte,
1008
1297
  // because there unknown is still fatal (R14).
1009
- 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") {
1010
1302
  const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
1011
1303
  // BAPI-1260 — only a SELECTED LOCAL plane is ever started here. A
1012
1304
  // profile approved for durable execution names an authority this
@@ -1019,12 +1311,9 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1019
1311
  if (endpoint !== null) {
1020
1312
  const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
1021
1313
  if (probe.kind === "refused") {
1022
- return coldStartPlane({
1023
- deps,
1024
- epicKey,
1025
- options: parsed.options,
1026
- access: accessResult.access,
1027
- });
1314
+ // `coldStartPlane` surfaces the containment advisory itself.
1315
+ await announceFoundPlan(false);
1316
+ return await coldStartPlane(ctx);
1028
1317
  }
1029
1318
  }
1030
1319
  }
@@ -1040,6 +1329,36 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1040
1329
  // delegated argv, or the exit code.
1041
1330
  if (planFile)
1042
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
+ }
1043
1362
  // Decide delegation BEFORE rendering. The v2 branch's text is specifically
1044
1363
  // about a missing plan file, and rendering it on the path that has one
1045
1364
  // would produce a message contradicting what is about to happen.
@@ -1049,7 +1368,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1049
1368
  // not-ready result can be rendered, because a report that is non-green only on
1050
1369
  // the runtime facts is not a "v2 cannot run" situation — it is a plane that
1051
1370
  // has not been started yet.
1052
- if (planFile) {
1371
+ if (ctx.options.planFile !== undefined) {
1053
1372
  // BAPI-1102 — the explicit `--attended` opt-out makes the unattended
1054
1373
  // prerequisites inapplicable. Both inputs are FLAG checks, not policy
1055
1374
  // reads: whatever a `--policy-file` says stays opaque here, exactly as it
@@ -1060,12 +1379,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1060
1379
  intoBase: parsed.options.intoBase === true,
1061
1380
  hasPolicyFile: parsed.options.policyFile !== undefined,
1062
1381
  });
1063
- const setupArgv = buildSetupEpicArgv(parsed.options);
1064
- // Green: delegate exactly as before. No plane invocation, no preflight,
1065
- // and byte-identical argv for the no-flag case. Only the structured
1066
- // 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.
1067
1386
  if (classification.kind === "green") {
1068
- 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);
1069
1400
  }
1070
1401
  // Operator-blocked: ONE named reason, and nothing is started. `plane up`
1071
1402
  // cannot create stored supervisor state or resolve GitHub App credentials,
@@ -1079,8 +1410,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1079
1410
  // The requirement's OWN remedy when it has one. Telling an operator to
1080
1411
  // run `install-bridge conductor` for a missing consent flag sends them
1081
1412
  // to a command that cannot set it.
1082
- classification.remedy ??
1083
- "Resolve it with `install-bridge conductor`, then run drive-epic again.",
1413
+ operatorBlockedRemedy(classification),
1084
1414
  ].join("\n");
1085
1415
  // Named as a fixed reason and nothing else: no conductor is offered here,
1086
1416
  // because handing an operator a second authority is the hazard this
@@ -1089,6 +1419,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1089
1419
  deps.errorLog(refusal);
1090
1420
  return 1;
1091
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
+ }
1092
1429
  // Runtime-only: this invocation composes bring-up with run creation.
1093
1430
  const repoRoot = deps.cwd();
1094
1431
  const preflight = await deps.runPlanePreflight(repoRoot);
@@ -1099,8 +1436,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1099
1436
  // preflight made from its single manifest read; nothing is re-read here.
1100
1437
  const existingPlane = preflight.observations?.existingPlane;
1101
1438
  const attachManifest = existingPlane?.kind === "attachable" &&
1102
- preflight.observations?.serverPort === "connected" &&
1103
- planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
1439
+ preflight.observations?.serverPort === "connected"
1104
1440
  ? existingPlane.manifest
1105
1441
  : null;
1106
1442
  // Composition preconditions FIRST, and only these two. Preflight is
@@ -1128,7 +1464,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1128
1464
  return 1;
1129
1465
  }
1130
1466
  if (attachManifest !== null) {
1131
- return attachToPlane({ deps, epicKey, options: parsed.options, access: accessResult.access }, attachManifest, readiness);
1467
+ return await attachToPlane(ctx, attachManifest, readiness);
1132
1468
  }
1133
1469
  // ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
1134
1470
  //
@@ -1149,23 +1485,21 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1149
1485
  const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1150
1486
  if (!controlPlane.ok) {
1151
1487
  // The launch already reported why, in its own words, and it rolls its own
1152
- // startup back. Nothing to add, and no run was created here.
1153
- for (const line of controlPlane.lines)
1154
- deps.errorLog(line);
1155
- return 1;
1488
+ // startup back. No run was created here.
1489
+ return reportControlPlaneLaunchFailure(ctx, controlPlane);
1156
1490
  }
1157
1491
  // Held so the launcher's lifetime promise is never unobserved. It settles
1158
1492
  // when the PLANE comes down, which is long after this command returns —
1159
1493
  // the detached runtime is its own process-group leader and outlives us by
1160
1494
  // design, which is what "the plane is left running" means.
1161
1495
  void controlPlane.lifetime.catch(() => undefined);
1162
- // Readiness is NOT re-read as a gate here, and that has not changed:
1163
- // `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
1164
- // and `executor_jobs` rows — tables that are EMPTY on a repository that has
1165
- // never run an epic — so gating on a re-read would hang the one-command
1166
- // flow permanently on exactly the first-time-operator case it exists to
1167
- // serve. A successful bring-up IS the runtime fact.
1168
- 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, {
1169
1503
  origin: "launched",
1170
1504
  repoRoot,
1171
1505
  planeId: controlPlane.planeId,
@@ -1187,14 +1521,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1187
1521
  deps.errorLog(refusal);
1188
1522
  return 1;
1189
1523
  }
1190
- const handoff = renderConductorHandoff(selection, epicKey, {
1191
- profile: launchProfile,
1192
- options: parsed.options,
1193
- });
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);
1194
1527
  const text = handoff.lines.join("\n");
1195
1528
  assertSingleConductorInvocation(text);
1196
- deps.log(text);
1197
- return 0;
1529
+ deps.errorLog(text);
1530
+ return 1;
1198
1531
  }
1199
1532
  catch (err) {
1200
1533
  // Last resort. Sanitized, on stderr, converted to an exit code — a
@@ -1226,6 +1559,23 @@ async function surfaceContainmentAdvisory(deps, planFile) {
1226
1559
  // Fail open — a containment advisory is never allowed to block dispatch.
1227
1560
  }
1228
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
+ }
1229
1579
  /**
1230
1580
  * The escalation branch. Prints NO conductor invocation at all — naming a
1231
1581
  * fallback here is the dual-authority hazard, and an operator who does not know
@@ -1236,8 +1586,8 @@ function escalate(deps, epicKey, reason) {
1236
1586
  `Cannot determine which conductor owns ${epicKey}: ${reason}`,
1237
1587
  "",
1238
1588
  "This is not a not-ready result — it is an unknown one, and starting the wrong",
1239
- "conductor on an epic wedges it permanently. Restore Bridge API access and run",
1240
- "drive-epic again, or ask an operator to resolve conductor readiness.",
1589
+ "conductor on an epic wedges it permanently.",
1590
+ ESCALATE_REMEDY,
1241
1591
  ];
1242
1592
  const text = lines.join("\n");
1243
1593
  assertSingleConductorInvocation(text);
@@ -1339,9 +1689,7 @@ async function coldStartPlane(ctx) {
1339
1689
  }
1340
1690
  const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1341
1691
  if (!controlPlane.ok) {
1342
- for (const line of controlPlane.lines)
1343
- deps.errorLog(line);
1344
- return 1;
1692
+ return reportControlPlaneLaunchFailure(ctx, controlPlane);
1345
1693
  }
1346
1694
  void controlPlane.lifetime.catch(() => undefined);
1347
1695
  // Read ONCE, for route selection only — the pre-launch read had no server to
@@ -1366,8 +1714,7 @@ async function coldStartPlane(ctx) {
1366
1714
  if (classification.kind === "operator_blocked") {
1367
1715
  return refuse(deps, [
1368
1716
  `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
1369
- classification.remedy ??
1370
- "Resolve it with `install-bridge conductor`, then run drive-epic again.",
1717
+ operatorBlockedRemedy(classification),
1371
1718
  ...LAUNCHED_PLANE_LEFT_RUNNING,
1372
1719
  ]);
1373
1720
  }
@@ -1409,7 +1756,10 @@ async function attachToPlane(ctx, manifest, readiness) {
1409
1756
  ]);
1410
1757
  }
1411
1758
  const attachCtx = { ...ctx, access: rooted.access };
1412
- // 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;
1413
1763
  if (manifest.epicRunId !== undefined) {
1414
1764
  let bound = null;
1415
1765
  try {
@@ -1418,28 +1768,33 @@ async function attachToPlane(ctx, manifest, readiness) {
1418
1768
  catch {
1419
1769
  bound = null;
1420
1770
  }
1421
- const why = bound === null
1422
- ? "its bound run could not be read"
1423
- : TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
1424
- ? `its bound run is ${bound.status}`
1425
- : bound.epicKey !== epicKey
1426
- ? `its bound run belongs to ${bound.epicKey}`
1427
- : null;
1428
- if (why !== null) {
1771
+ if (bound === null) {
1429
1772
  return refuse(deps, [
1430
1773
  `${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
1431
- `${manifest.epicRunId}, and ${why}.`,
1432
- "A plane serves exactly one epic run (one epic per plane). Finish or stop that run, wind the",
1433
- "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.",
1434
1778
  "",
1435
1779
  "Nothing was started.",
1436
1780
  ]);
1437
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
+ }
1438
1788
  }
1439
1789
  const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
1440
1790
  if (admission !== null) {
1441
1791
  return refuse(deps, [...admission, "", "Nothing was started."]);
1442
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
+ }
1443
1798
  deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
1444
1799
  return createRunAndRequestLanes(attachCtx, {
1445
1800
  origin: "attached",
@@ -1523,7 +1878,8 @@ async function createRunAndRequestLanes(ctx, plane) {
1523
1878
  `${epicKey}: the run was created and approved, but executor lanes could not be`,
1524
1879
  `requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
1525
1880
  laneRequest.reason === "run-conflict"
1526
- ? "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."
1527
1883
  : "Inspect it with `plane status`, and wind it down with `plane down`.",
1528
1884
  "The run itself is untouched and still committed server-side.",
1529
1885
  ].join("\n"));
@@ -1576,3 +1932,742 @@ async function createRunAndRequestLanes(ctx, plane) {
1576
1932
  ].join("\n"));
1577
1933
  return 0;
1578
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
+ }