@bridge_gpt/mcp-server 0.2.51 → 0.2.52

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 (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. package/package.json +3 -2
@@ -51,8 +51,13 @@
51
51
  * ratcheted down, and a tool would be useless in a bare-chat session anyway,
52
52
  * where the model has no tool call to make.
53
53
  */
54
+ import { readFile } from "node:fs/promises";
55
+ import { dirname, join } from "node:path";
54
56
  import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
55
57
  import { runSetupEpicCli } from "./setup-epic.js";
58
+ import { formatPlaneDiagnostic, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
59
+ import { PLANE_SERVER_PORT_ENV_VAR } from "./plane/types.js";
60
+ import { validateBranchName } from "./base-ref.js";
56
61
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
57
62
  // the printed invocation interpolates it rather than repeating it.
58
63
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
@@ -106,6 +111,37 @@ export const V2_READINESS_REQUIREMENTS = [
106
111
  satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
107
112
  },
108
113
  ];
114
+ /**
115
+ * The readiness facts a `plane up` can actually repair (BAPI-1054).
116
+ *
117
+ * Both are liveness of a process this machine starts. The other three —
118
+ * `supervisor_setup`, `supervisor_config`, `github_credentials` — are stored
119
+ * server-side state or credentials, and no amount of starting processes creates
120
+ * them. Naming the repairable set here, once, is what keeps the composition
121
+ * from ever attempting a bring-up that could not have helped.
122
+ */
123
+ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
124
+ /**
125
+ * Classify the SERVER's readiness report into the composition's three cases.
126
+ *
127
+ * Pure, and it re-derives no fact: it filters {@link V2_READINESS_REQUIREMENTS}
128
+ * with the same predicates {@link selectConductor} uses, so the router and the
129
+ * composition can never disagree about whether v2 can run.
130
+ *
131
+ * `operator_blocked` names the FIRST unmet non-runtime requirement in the stable
132
+ * declaration order. One deterministic reason, not a list: an operator handed
133
+ * three competing actions has to work out which to do first, and the order here
134
+ * already encodes that (setup, then config, then credentials).
135
+ */
136
+ export function classifyV2Readiness(readiness) {
137
+ const unmet = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
138
+ if (unmet.length === 0)
139
+ return { kind: "green" };
140
+ const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
141
+ if (blocker)
142
+ return { kind: "operator_blocked", id: blocker.id, describe: blocker.describe };
143
+ return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
144
+ }
109
145
  export function getDriveEpicUsage() {
110
146
  return [
111
147
  "Usage: mcp-server drive-epic [options] <EPIC>",
@@ -117,11 +153,44 @@ export function getDriveEpicUsage() {
117
153
  "Arguments:",
118
154
  " <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
119
155
  "",
156
+ "Branch strategy (v2): a plan with two or more nodes defaults to a dedicated",
157
+ "epic/<KEY> branch — child PRs target it, one draft integration PR targets the",
158
+ "repository base branch. Both flags below only override that default; the",
159
+ "strategy itself is decided by the bootstrap this command delegates to, never",
160
+ "here.",
161
+ "",
162
+ "One command (BAPI-1054): with --plan-file, drive-epic starts the conductor",
163
+ "runtime for you when readiness is missing ONLY the reconciler/executor facts,",
164
+ "then creates and approves the run — one invocation, no manual `plane up` first.",
165
+ "A report missing supervisor setup, supervisor configuration, or GitHub App",
166
+ "credentials is NOT started: those are operator-owned, and drive-epic exits with",
167
+ "that one named reason instead.",
168
+ "",
169
+ "Preconditions are validated, never repaired: the current directory must be a Git",
170
+ "work tree, and the local server port must be free. No worktree is created for",
171
+ "you and no replacement port is chosen for you — each failure names the fix (for",
172
+ `the port, set ${PLANE_SERVER_PORT_ENV_VAR} to a free one). Fix it and rerun.`,
173
+ "",
174
+ "Manual fallback (still supported): run `plane up` yourself, then rerun this",
175
+ "command with the same --plan-file against the now-live plane.",
176
+ "",
120
177
  "Options:",
121
178
  " --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
122
179
  " selected, drive-epic runs that bootstrap directly",
123
180
  " instead of printing the command to run.",
124
181
  " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
182
+ " --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
183
+ " verbatim, never interpreted here.",
184
+ " --into-base OPT OUT of the epic-branch default: children target the",
185
+ " repository base branch (the pre-BAPI-1009 behavior).",
186
+ " Contradictory with --feature-branch; passing both is a",
187
+ " parse error.",
188
+ "",
189
+ "Policy (forwarded verbatim; drive-epic interprets none of it):",
190
+ " --policy-file <path> JSON file holding the COMPLETE run policy.",
191
+ " --review-policy <src> PER-RUN review policy source.",
192
+ " --replace-policy Authorize replacing a LIVE run's stored policy.",
193
+ "",
125
194
  " -h, --help Show this help",
126
195
  ].join("\n");
127
196
  }
@@ -136,28 +205,88 @@ export function parseDriveEpicArgs(argv) {
136
205
  const positionals = [];
137
206
  let planFile;
138
207
  let repo;
208
+ let featureBranch;
209
+ // Tracked separately from the value so a blank `--feature-branch=` still
210
+ // registers as a stated branch-strategy intent for the contradiction guard.
211
+ let featureBranchSupplied = false;
212
+ let intoBase = false;
213
+ // BAPI-1054 policy pass-throughs. Captured, never interpreted: `--review-policy`
214
+ // is NOT validated against the vocabulary here, because setup-epic already
215
+ // refuses an unknown value before any I/O and a second copy of that list is
216
+ // exactly the drift the single-entry-point rule exists to prevent.
217
+ let policyFile;
218
+ let reviewPolicy;
219
+ let replacePolicy = false;
220
+ // BAPI-1009: validated with the SHARED validator setup-epic uses, so a name
221
+ // drive-epic accepts is exactly a name setup-epic accepts. A second rule here
222
+ // would let a name pass the router and fail the delegate.
223
+ const takeFeatureBranch = (raw) => {
224
+ featureBranchSupplied = true;
225
+ const trimmed = raw.trim();
226
+ if (trimmed === "")
227
+ return null;
228
+ if (validateBranchName(trimmed) !== null)
229
+ return null;
230
+ featureBranch = trimmed;
231
+ return trimmed;
232
+ };
139
233
  for (let i = 0; i < argv.length; i++) {
140
234
  const arg = argv[i];
141
- if (arg === "--plan-file" || arg === "--repo") {
235
+ if (arg === "--into-base") {
236
+ intoBase = true;
237
+ continue;
238
+ }
239
+ if (arg === "--replace-policy") {
240
+ replacePolicy = true;
241
+ continue;
242
+ }
243
+ if (arg === "--plan-file" ||
244
+ arg === "--repo" ||
245
+ arg === "--feature-branch" ||
246
+ arg === "--policy-file" ||
247
+ arg === "--review-policy") {
142
248
  const value = argv[i + 1];
143
249
  if (value === undefined || value.startsWith("-")) {
144
250
  return { status: "error", message: `${arg} requires a value.` };
145
251
  }
146
252
  if (arg === "--plan-file")
147
253
  planFile = value;
148
- else
254
+ else if (arg === "--repo")
149
255
  repo = value;
256
+ else if (arg === "--policy-file")
257
+ policyFile = value;
258
+ else if (arg === "--review-policy")
259
+ reviewPolicy = value;
260
+ else if (takeFeatureBranch(value) === null) {
261
+ return {
262
+ status: "error",
263
+ message: `--feature-branch requires a valid, nonblank branch name, got '${value}'.`,
264
+ };
265
+ }
150
266
  i++;
151
267
  continue;
152
268
  }
153
- const eq = arg.match(/^(--plan-file|--repo)=(.*)$/);
269
+ const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy)=(.*)$/);
154
270
  if (eq) {
155
271
  const value = eq[2];
272
+ if (eq[1] === "--feature-branch") {
273
+ if (takeFeatureBranch(value) === null) {
274
+ return {
275
+ status: "error",
276
+ message: `--feature-branch requires a valid, nonblank branch name, got '${value}'.`,
277
+ };
278
+ }
279
+ continue;
280
+ }
156
281
  if (value.trim().length === 0) {
157
282
  return { status: "error", message: `${eq[1]} requires a value.` };
158
283
  }
159
284
  if (eq[1] === "--plan-file")
160
285
  planFile = value;
286
+ else if (eq[1] === "--policy-file")
287
+ policyFile = value;
288
+ else if (eq[1] === "--review-policy")
289
+ reviewPolicy = value;
161
290
  else
162
291
  repo = value;
163
292
  continue;
@@ -167,6 +296,18 @@ export function parseDriveEpicArgs(argv) {
167
296
  }
168
297
  positionals.push(arg);
169
298
  }
299
+ // BAPI-1009: refused HERE — before access resolution, before the readiness
300
+ // request, and before any delegation — with the same corrective wording
301
+ // setup-epic uses, so the two entry points explain one mistake one way.
302
+ if (featureBranchSupplied && intoBase) {
303
+ return {
304
+ status: "error",
305
+ message: "--feature-branch and --into-base select contradictory branch strategies: " +
306
+ "--feature-branch runs the epic on a named epic branch, --into-base runs " +
307
+ "children directly into the repository base branch. Only one branch strategy " +
308
+ "may be selected — remove whichever one you did not mean.",
309
+ };
310
+ }
170
311
  if (positionals.length === 0) {
171
312
  return { status: "error", message: "Missing required epic key." };
172
313
  }
@@ -183,7 +324,16 @@ export function parseDriveEpicArgs(argv) {
183
324
  if (!EPIC_KEY_PATTERN.test(epicKey)) {
184
325
  return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. BAPI-885.` };
185
326
  }
186
- const options = { epicKey, ...(planFile ? { planFile } : {}), ...(repo ? { repo } : {}) };
327
+ const options = {
328
+ epicKey,
329
+ ...(planFile ? { planFile } : {}),
330
+ ...(repo ? { repo } : {}),
331
+ ...(featureBranch ? { featureBranch } : {}),
332
+ ...(intoBase ? { intoBase: true } : {}),
333
+ ...(policyFile ? { policyFile } : {}),
334
+ ...(reviewPolicy ? { reviewPolicy } : {}),
335
+ ...(replacePolicy ? { replacePolicy: true } : {}),
336
+ };
187
337
  return { status: "ok", options };
188
338
  }
189
339
  /**
@@ -209,6 +359,32 @@ export function selectConductor(readiness) {
209
359
  reason: `the engine path still needs ${missing.map((m) => m.describe).join("; ")}`,
210
360
  };
211
361
  }
362
+ /**
363
+ * Build the exact `setup-epic` argument array (BAPI-1054).
364
+ *
365
+ * Extracted so every delegation path — green, and composed-after-bring-up —
366
+ * forwards byte-identical argv. Two inline copies of this array is how one path
367
+ * silently stops forwarding a flag the other does.
368
+ *
369
+ * Every optional flag is APPENDED only when supplied, so the no-flag array stays
370
+ * byte-identical to the pre-BAPI-1009 one and setup-epic applies its own
371
+ * node-count-aware default. An exact argument array: no shell, no interpolation,
372
+ * no quoting to get wrong.
373
+ */
374
+ export function buildSetupEpicArgv(options) {
375
+ const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
376
+ return [
377
+ "--epic-key",
378
+ epicKey,
379
+ ...(planFile ? ["--plan-file", planFile] : []),
380
+ ...(repo ? ["--repo", repo] : []),
381
+ ...(featureBranch ? ["--feature-branch", featureBranch] : []),
382
+ ...(intoBase ? ["--into-base"] : []),
383
+ ...(policyFile ? ["--policy-file", policyFile] : []),
384
+ ...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
385
+ ...(replacePolicy ? ["--replace-policy"] : []),
386
+ ];
387
+ }
212
388
  /**
213
389
  * Render the selected path and only the selected path.
214
390
  *
@@ -281,6 +457,138 @@ export function assertSingleConductorInvocation(text) {
281
457
  "Exactly one path may ever be presented.");
282
458
  }
283
459
  }
460
+ /**
461
+ * Conservative bound on any single advisory field. Exists so malformed or
462
+ * adversarial artifact content cannot turn into multiline or oversized
463
+ * terminal output — the advisory is meant to be a few compact scan lines.
464
+ */
465
+ const MAX_CONTAINMENT_ADVISORY_FIELD_LENGTH = 300;
466
+ function isCompactSingleLineString(value) {
467
+ return (typeof value === "string" &&
468
+ value.trim().length > 0 &&
469
+ value.length <= MAX_CONTAINMENT_ADVISORY_FIELD_LENGTH &&
470
+ !value.includes("\n") &&
471
+ !value.includes("\r"));
472
+ }
473
+ function isCompactHazardEvidenceArray(value) {
474
+ return Array.isArray(value) && value.length > 0 && value.every(isCompactSingleLineString);
475
+ }
476
+ /**
477
+ * Parse unknown JSON into a compact internal containment advisory.
478
+ *
479
+ * Requires `status: "assessed"`, a positive integer `containmentChildren`,
480
+ * and every `affectedChildren` entry with `requiresContainmentReview: true`
481
+ * to carry valid compact `id`/`title`/`matchedHazards` fields. The number of
482
+ * such flagged records must agree with `containmentChildren` exactly —
483
+ * disagreement, an invalid evidence array, or an incomplete flagged-child
484
+ * record is treated as malformed and yields no advisory (`null`), never a
485
+ * partial or best-effort one. Never reimplements the Python hazard allowlist
486
+ * — this function only validates shape, not hazard content.
487
+ */
488
+ export function parseContainmentEligibilityArtifact(raw) {
489
+ if (raw === null || typeof raw !== "object")
490
+ return null;
491
+ const artifact = raw;
492
+ if (artifact.status !== "assessed")
493
+ return null;
494
+ const containmentChildren = artifact.containmentChildren;
495
+ if (!Number.isInteger(containmentChildren) || containmentChildren <= 0) {
496
+ return null;
497
+ }
498
+ if (!Array.isArray(artifact.affectedChildren))
499
+ return null;
500
+ const flagged = [];
501
+ for (const entry of artifact.affectedChildren) {
502
+ if (entry === null || typeof entry !== "object")
503
+ return null;
504
+ const child = entry;
505
+ if (child.requiresContainmentReview !== true)
506
+ continue;
507
+ if (!isCompactSingleLineString(child.id) || !isCompactSingleLineString(child.title))
508
+ return null;
509
+ if (!isCompactHazardEvidenceArray(child.matchedHazards))
510
+ return null;
511
+ flagged.push({ id: child.id, title: child.title, matchedHazards: [...child.matchedHazards] });
512
+ }
513
+ if (flagged.length !== containmentChildren)
514
+ return null;
515
+ if (flagged.length === 0)
516
+ return null;
517
+ return { containmentChildren: containmentChildren, flagged };
518
+ }
519
+ /**
520
+ * Locate and read the containment-eligibility sidecar for a supplied plan
521
+ * file, then parse it. `conductor-eligibility.json` is always a sibling of
522
+ * the plan file — `dirname(planFile)/conductor-eligibility.json` — the same
523
+ * directory `assess-conductor-eligibility` writes it to. Fails open on every
524
+ * boundary: a resolution failure, a rejected read (missing file, permission
525
+ * error), invalid JSON, or a malformed/inconsistent artifact all resolve to
526
+ * `null`, never a thrown error.
527
+ */
528
+ export async function readContainmentEligibilityAdvisory(planFile, readEligibilityArtifact) {
529
+ try {
530
+ const artifactPath = join(dirname(planFile), "conductor-eligibility.json");
531
+ const raw = await readEligibilityArtifact(artifactPath);
532
+ const parsed = JSON.parse(raw);
533
+ return parseContainmentEligibilityArtifact(parsed);
534
+ }
535
+ catch {
536
+ return null;
537
+ }
538
+ }
539
+ /**
540
+ * Render a validated advisory as compact, factual, advisory-only text.
541
+ *
542
+ * Contains no remediation instructions and no conductor-invocation wording —
543
+ * guarded against both the module's own static heading text and any
544
+ * artifact-supplied title/evidence value that would introduce a
545
+ * {@link CONDUCTOR_INVOCATION_TOKENS} value. When safe rendering cannot be
546
+ * proved, this suppresses the advisory (`null`) rather than throwing or
547
+ * emitting unsafe text; the caller must never let this fail the CLI.
548
+ */
549
+ export function renderContainmentEligibilityAdvisory(advisory) {
550
+ const lines = [
551
+ "Review containment hazard before dispatch:",
552
+ ...advisory.flagged.map((child) => ` - ${child.id}: ${child.title} (${child.matchedHazards.join(", ")})`),
553
+ ];
554
+ const text = lines.join("\n");
555
+ const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
556
+ if (named.length > 0)
557
+ return null;
558
+ return text;
559
+ }
560
+ /**
561
+ * The blocking checks the COMPOSITION owns, in refusal order (BAPI-1054).
562
+ *
563
+ * `plane up`'s own preflight deliberately aggregates every blocking diagnostic —
564
+ * build freshness, credentials, Alembic head, an existing plane — and reports
565
+ * them together, because five separate one-minute retries is the failure mode
566
+ * that convention exists to prevent. That aggregate stays exactly as it is.
567
+ *
568
+ * These two are different: they are the ticket's named composition
569
+ * PRECONDITIONS, checked before anything is started, and each carries a single
570
+ * concrete remediation the operator performs by hand. "Exactly one named reason"
571
+ * is therefore a property of this narrow set, never of `plane up`'s refusal.
572
+ */
573
+ export const DRIVE_EPIC_PRECONDITION_CHECKS = [
574
+ "worktree-presence",
575
+ "server-port",
576
+ ];
577
+ /**
578
+ * The first composition-owned blocking diagnostic, or `null`.
579
+ *
580
+ * Order comes from {@link DRIVE_EPIC_PRECONDITION_CHECKS}, not from the order
581
+ * preflight happened to append them, so the reason an operator sees for a given
582
+ * pair of failures is stable.
583
+ */
584
+ export function firstPreconditionRefusal(preflight) {
585
+ for (const check of DRIVE_EPIC_PRECONDITION_CHECKS) {
586
+ const found = preflight.diagnostics.find((d) => d.check === check && d.severity === "blocking");
587
+ if (found)
588
+ return found;
589
+ }
590
+ return null;
591
+ }
284
592
  export function createDefaultDriveEpicDeps() {
285
593
  return {
286
594
  resolveAccess: async (repo) => {
@@ -289,6 +597,10 @@ export function createDefaultDriveEpicDeps() {
289
597
  },
290
598
  readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
291
599
  runSetupEpic: (argv) => runSetupEpicCli(argv),
600
+ readEligibilityArtifact: (path) => readFile(path, "utf8"),
601
+ runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
602
+ runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
603
+ cwd: () => process.cwd(),
292
604
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
293
605
  // construction, so nothing has claimed stdout for the protocol transport.
294
606
  log: (message) => console.log(message),
@@ -314,7 +626,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
314
626
  deps.errorLog(getDriveEpicUsage());
315
627
  return 1;
316
628
  }
317
- const { epicKey, planFile, repo } = parsed.options;
629
+ const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
318
630
  try {
319
631
  // --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
320
632
  const accessResult = await deps.resolveAccess(repo);
@@ -331,15 +643,115 @@ export async function runDriveEpicCli(argv, overrides = {}) {
331
643
  }
332
644
  // --- Route. Exactly one path from here on. -------------------------------
333
645
  const selection = selectConductor(readiness);
646
+ // BAPI-1022: surface any containment-hazard advisory before dispatch or
647
+ // handoff. Read only when a plan file was supplied (the sidecar has no
648
+ // other resolvable location). Fail-open: any failure here — missing
649
+ // artifact, bad JSON, malformed/inconsistent shape, an unsafe rendered
650
+ // value — silently yields no advisory and never affects routing, the
651
+ // delegated argv, or the exit code.
652
+ if (planFile) {
653
+ try {
654
+ const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
655
+ if (advisory) {
656
+ const rendered = renderContainmentEligibilityAdvisory(advisory);
657
+ if (rendered)
658
+ deps.log(rendered);
659
+ }
660
+ }
661
+ catch {
662
+ // Fail open — a containment advisory is never allowed to block dispatch.
663
+ }
664
+ }
334
665
  // Decide delegation BEFORE rendering. The v2 branch's text is specifically
335
666
  // about a missing plan file, and rendering it on the path that has one
336
667
  // would produce a message contradicting what is about to happen.
337
- if (selection.conductor === "v2" && planFile) {
338
- // Delegate. `setup-epic` owns branch, run-row, plan, and approval
339
- // behavior; drive-epic reimplements none of it and prints nothing of its
340
- // own alongside it, so its output remains the only conductor output.
341
- const setupArgv = ["--epic-key", epicKey, "--plan-file", planFile, ...(repo ? ["--repo", repo] : [])];
342
- return await deps.runSetupEpic(setupArgv);
668
+ //
669
+ // BAPI-1054: a supplied plan file is what makes this a BRING-UP decision
670
+ // rather than a routing one. Everything below runs before `selectConductor`'s
671
+ // pilot fallback can be rendered, because a report that is non-green only on
672
+ // the runtime facts is not a "use the other conductor" situation it is a
673
+ // plane that has not been started yet.
674
+ if (planFile) {
675
+ const classification = classifyV2Readiness(readiness);
676
+ const setupArgv = buildSetupEpicArgv(parsed.options);
677
+ // Green: delegate exactly as before. No plane invocation, no preflight,
678
+ // and byte-identical argv for the no-flag case.
679
+ if (classification.kind === "green") {
680
+ return await deps.runSetupEpic(setupArgv);
681
+ }
682
+ // Operator-blocked: ONE named reason, and nothing is started. `plane up`
683
+ // cannot create stored supervisor state or resolve GitHub App credentials,
684
+ // so bringing it up would leave a running plane behind and change nothing.
685
+ if (classification.kind === "operator_blocked") {
686
+ const refusal = [
687
+ `${epicKey}: the engine path still needs ${classification.describe} ` +
688
+ `(${classification.id}).`,
689
+ "",
690
+ "This is not something starting the runtime can fix, so nothing was started.",
691
+ "Resolve it with `install-bridge conductor`, then run drive-epic again.",
692
+ ].join("\n");
693
+ // Named as a fixed reason and nothing else: no conductor is offered here,
694
+ // because handing an operator a second authority is the hazard this
695
+ // subcommand exists to remove.
696
+ assertSingleConductorInvocation(refusal);
697
+ deps.errorLog(refusal);
698
+ return 1;
699
+ }
700
+ // Runtime-only: this invocation composes bring-up with run creation.
701
+ const repoRoot = deps.cwd();
702
+ const preflight = await deps.runPlanePreflight(repoRoot);
703
+ // Composition preconditions FIRST, and only these two. Preflight is
704
+ // read-only, so a refusal here provably created no manifest, spawned no
705
+ // process, and issued no run-creating request — AC-1's "no partial run"
706
+ // holds structurally rather than by cleanup.
707
+ const precondition = firstPreconditionRefusal(preflight);
708
+ if (precondition) {
709
+ const refusal = [
710
+ `${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
711
+ formatPlaneDiagnostic(precondition),
712
+ "",
713
+ "Nothing was started. Fix the item above and run drive-epic again.",
714
+ ].join("\n");
715
+ assertSingleConductorInvocation(refusal);
716
+ deps.errorLog(refusal);
717
+ return 1;
718
+ }
719
+ deps.log(`${epicKey}: the conductor runtime is not live; starting it before creating the run.`);
720
+ // The SAME preflight result is handed to `plane up` through its existing
721
+ // override seam, so the port that was probed is the port that gets launched
722
+ // and the two cannot disagree. Every blocking failure `plane up` finds
723
+ // beyond the two preconditions above is rendered by its own aggregate
724
+ // refusal, unchanged.
725
+ const planeExit = await deps.runPlane(["up"], { preflight: async () => preflight });
726
+ if (planeExit !== 0) {
727
+ // `plane up` already reported why, in its own words, and it rolls its own
728
+ // startup back. Nothing to add, and nothing was created here.
729
+ return 1;
730
+ }
731
+ // Delegate immediately. The readiness report is NOT re-read as a gate:
732
+ // `plane up` completes only after each member's durable heartbeat is fresh,
733
+ // whereas the readiness report derives reconciler/executor liveness from
734
+ // `epic_runs.last_tick_at` and `executor_jobs` rows — tables that are empty
735
+ // on a repository that has never run an epic. Gating on a re-read would
736
+ // therefore hang the one-command flow permanently on exactly the
737
+ // first-time-operator case it exists to serve. A successful bring-up IS the
738
+ // runtime fact here; re-sourcing that predicate belongs to A1b.4.
739
+ const setupExit = await deps.runSetupEpic(setupArgv);
740
+ if (setupExit !== 0) {
741
+ // The plane is deliberately LEFT RUNNING. `plane down` stops the
742
+ // manifest-bound run before terminating processes, so winding down now
743
+ // would stop the very run setup-epic may have just created, bound, or
744
+ // approved. The live plane is reported rather than abandoned — the
745
+ // operator owns the decision, which is the documented two-step state.
746
+ deps.errorLog([
747
+ "",
748
+ `${epicKey}: the conductor runtime was started by this invocation and is STILL RUNNING.`,
749
+ "It was not wound down automatically, because doing so would stop a run that may",
750
+ "already have been created and approved. Inspect it with `plane status`, and wind it",
751
+ "down with `plane down` once you have decided what to do with the run.",
752
+ ].join("\n"));
753
+ }
754
+ return setupExit;
343
755
  }
344
756
  const handoff = renderConductorHandoff(selection, epicKey);
345
757
  const text = handoff.lines.join("\n");