@bridge_gpt/mcp-server 0.2.53 → 0.2.54

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 (40) hide show
  1. package/README.md +86 -10
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +1 -1
  7. package/build/conductor/bridge-api-client.js +36 -8
  8. package/build/conductor/epic-runtime.js +133 -97
  9. package/build/conductor/readiness.js +85 -0
  10. package/build/conductor/run-branch.js +137 -0
  11. package/build/conductor/test-run-branch-vectors.js +165 -0
  12. package/build/conductor-bin.js +5 -5
  13. package/build/doctor.js +68 -1
  14. package/build/drive-epic.js +287 -51
  15. package/build/executor/claim-scope.js +104 -0
  16. package/build/executor/cli.js +14 -25
  17. package/build/executor/env-file-guard.js +82 -3
  18. package/build/executor/job-runner.js +60 -0
  19. package/build/index.js +128 -400
  20. package/build/local-artifact-storage.js +130 -0
  21. package/build/pipelines.generated.js +16 -9
  22. package/build/plane/cli.js +285 -36
  23. package/build/plane/manifest.js +209 -1
  24. package/build/plane/member-roster.js +70 -0
  25. package/build/plane/shutdown.js +14 -1
  26. package/build/plane/status.js +35 -1
  27. package/build/plane/supervisor.js +546 -164
  28. package/build/plane/types.js +25 -2
  29. package/build/polling-policy.js +72 -0
  30. package/build/readme.generated.js +1 -1
  31. package/build/review-generation.js +219 -0
  32. package/build/run-unit-tests-launcher.js +5 -0
  33. package/build/setup-epic.js +514 -23
  34. package/build/ticket-key-utils.js +4 -3
  35. package/build/ticket-review-artifact-gate.js +461 -0
  36. package/build/upgrade-cli.js +5 -26
  37. package/build/version.generated.js +3 -3
  38. package/docs/install/mcp-tool-integrations.md +23 -1
  39. package/package.json +1 -1
  40. package/pipelines/review-ticket.json +17 -4
package/build/doctor.js CHANGED
@@ -161,6 +161,13 @@ export function getDoctorUsage() {
161
161
  "deletes a branch, or repairs any git state, and never changes the exit code.",
162
162
  "Without --stale-branch it runs no git probe at all and prints no section.",
163
163
  "",
164
+ "The 'operational advisories' section also reports RETIRED local drive helpers",
165
+ "(~/.local/bin/bapi-runtime-up, ~/.local/bin/bapi-drive) when they are still on",
166
+ "this machine (BAPI-1102). That is a PATH-PRESENCE check only: doctor does not",
167
+ "run, read, source, chmod, or delete either file, and cannot remove them for you",
168
+ "— they are outside this repository, so their removal is an operator step. The",
169
+ "row is advisory and never changes the exit code.",
170
+ "",
164
171
  "Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
165
172
  ].join("\n");
166
173
  }
@@ -1413,6 +1420,24 @@ export function formatConductEpicDiagnosticReport(diagnostic) {
1413
1420
  lines.push(" lock, rewrites no checkpoint, and never changes the exit code.");
1414
1421
  return lines.join("\n");
1415
1422
  }
1423
+ /**
1424
+ * The transitional local helpers BAPI-1102 retires.
1425
+ *
1426
+ * `~/.local/bin` scripts, never shipped in this package and not in this
1427
+ * repository, so nothing here can delete them — removal is an OPERATOR step. What
1428
+ * the repository CAN do is notice them and say so, which is the difference
1429
+ * between a documented retirement and a silent one: an operator whose muscle
1430
+ * memory still types `bapi-drive` would otherwise keep driving epics through a
1431
+ * path that predates the scoped-lane bring-up, and diagnose the resulting
1432
+ * unscoped run as a server problem.
1433
+ *
1434
+ * Relative to the home directory, joined at collection time so no absolute path
1435
+ * is baked into the module.
1436
+ */
1437
+ export const LEGACY_DRIVE_HELPER_RELATIVE_PATHS = [
1438
+ ".local/bin/bapi-runtime-up",
1439
+ ".local/bin/bapi-drive",
1440
+ ];
1416
1441
  /**
1417
1442
  * Collect the operational advisories, read-only.
1418
1443
  *
@@ -1514,7 +1539,30 @@ export async function collectOperationalAdvisories(deps) {
1514
1539
  conductorObservation = "observed";
1515
1540
  }
1516
1541
  }
1517
- return { dispatcher, conductorObservation, conductors, indeterminateLocks };
1542
+ // BAPI-1102 a pure path-presence check, using the SAME injected `stat` and
1543
+ // `homedir` seams every other advisory here uses, so it stays deterministic
1544
+ // under test and reaches no real filesystem in one. A `stat` that throws is
1545
+ // "absent"; it is never retried, and a permission error is not reported as a
1546
+ // finding, because "I could not stat it" is not evidence that a stale helper is
1547
+ // in use.
1548
+ const legacyDriveHelpers = [];
1549
+ for (const relative of LEGACY_DRIVE_HELPER_RELATIVE_PATHS) {
1550
+ const absolute = path.join(deps.homedir(), relative);
1551
+ try {
1552
+ await deps.stat(absolute);
1553
+ legacyDriveHelpers.push(absolute);
1554
+ }
1555
+ catch {
1556
+ /* absent, or unreadable — either way, nothing to report */
1557
+ }
1558
+ }
1559
+ return {
1560
+ dispatcher,
1561
+ conductorObservation,
1562
+ conductors,
1563
+ indeterminateLocks,
1564
+ legacyDriveHelpers,
1565
+ };
1518
1566
  }
1519
1567
  /** Render the operational-advisories section. Advisory: never changes the exit code. */
1520
1568
  export function formatOperationalAdvisoryReport(diagnostic) {
@@ -1554,6 +1602,21 @@ export function formatOperationalAdvisoryReport(diagnostic) {
1554
1602
  lines.push(` ${diagnostic.indeterminateLocks} local lock(s) could not be judged (remote host,\n` +
1555
1603
  " malformed, or unreadable) and are excluded from the live count.");
1556
1604
  }
1605
+ // BAPI-1102. A WARN, never a failure, and deliberately separate from every
1606
+ // readiness signal above: a stale helper says nothing about whether this
1607
+ // repository's conductor works, so classifying it as a readiness problem would
1608
+ // report a perfectly healthy install as runtime-broken and could block a setup
1609
+ // that has nothing wrong with it.
1610
+ if (diagnostic.legacyDriveHelpers.length > 0) {
1611
+ lines.push(` WARN Legacy local drive helpers detected: ${diagnostic.legacyDriveHelpers.length} file(s).`);
1612
+ for (const helper of diagnostic.legacyDriveHelpers) {
1613
+ lines.push(` ${helper}`);
1614
+ }
1615
+ lines.push(" These predate the scoped two-phase bring-up and are retired. Confirm\n" +
1616
+ " `drive-epic <EPIC> --plan-file <dag>` works for you, then remove them.\n" +
1617
+ " doctor performed a PATH-PRESENCE check only: it did not run, read, or\n" +
1618
+ " modify either file, and it cannot delete them for you.");
1619
+ }
1557
1620
  lines.push(" Read-only: this section issues two Bridge GETs and reads local lock state. It");
1558
1621
  lines.push(" runs NO command probe, repairs no scheduler, and never changes the exit code.");
1559
1622
  return lines.join("\n");
@@ -1927,6 +1990,10 @@ export async function runDoctorCli(argv, overrides = {}) {
1927
1990
  conductorObservation: "unavailable",
1928
1991
  conductors: [],
1929
1992
  indeterminateLocks: 0,
1993
+ // Empty, not omitted: a collection that failed has not established
1994
+ // that a legacy helper is absent, and a fallback that CLAIMED to have
1995
+ // checked would be the one kind of wrong answer worse than silence.
1996
+ legacyDriveHelpers: [],
1930
1997
  }));
1931
1998
  }
1932
1999
  }
@@ -54,8 +54,8 @@
54
54
  import { readFile } from "node:fs/promises";
55
55
  import { dirname, join } from "node:path";
56
56
  import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
57
- import { runSetupEpicCli } from "./setup-epic.js";
58
- import { formatPlaneDiagnostic, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
57
+ import { runSetupEpicWorkflow, } from "./setup-epic.js";
58
+ import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
59
59
  import { PLANE_SERVER_PORT_ENV_VAR } from "./plane/types.js";
60
60
  import { validateBranchName } from "./base-ref.js";
61
61
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
@@ -110,15 +110,62 @@ export const V2_READINESS_REQUIREMENTS = [
110
110
  describe: "an executor provisioned and reporting ready",
111
111
  satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
112
112
  },
113
+ // BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
114
+ // refuses them, so readiness and the refusal name the same first blocker.
115
+ //
116
+ // Each reads the SERVER's `unattended` block and re-derives nothing. A server
117
+ // that does not report the block (older than BAPI-1102) leaves every one of
118
+ // them UNSATISFIED: "could not be read" is not "holds", and these authorize an
119
+ // unattended merge.
120
+ //
121
+ // The FIRST of them therefore carries the older-server remedy too, because it
122
+ // is the one an unreported block always blocks on, and telling that operator
123
+ // to set a consent flag would send them to fix something that may already be
124
+ // set — the server simply never said (BAPI-1102 review).
125
+ {
126
+ id: "unattended_consent",
127
+ describe: "repository consent to unattended conductor runs",
128
+ unattendedOnly: true,
129
+ // No fixed `remedy`: this requirement's action always depends on WHY.
130
+ remedyFor: (r) => r.unattended === null || r.unattended === undefined
131
+ ? "This Bridge API deploy does not report a repository's unattended " +
132
+ "prerequisites at all, so none of them could be confirmed. Upgrade the " +
133
+ "deploy, or pass --attended."
134
+ : "Set `unattended_conductor_allowed` for this repository " +
135
+ "(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
136
+ satisfied: (r) => r.unattended?.conductor_allowed === true,
137
+ },
138
+ {
139
+ id: "unattended_notify_default",
140
+ describe: "a verified notify webhook default for escalation",
141
+ unattendedOnly: true,
142
+ remedy: "Supply a --policy-file declaring notify.webhook_url, or pass --attended. " +
143
+ "notify.local_sink does not satisfy this.",
144
+ satisfied: (r) => r.unattended?.notify_webhook_default_declared === true,
145
+ },
146
+ {
147
+ id: "repository_readiness",
148
+ describe: "confirmed review and conductor-CI workflows on the default branch",
149
+ unattendedOnly: true,
150
+ remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
151
+ satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
152
+ },
113
153
  ];
114
154
  /**
115
155
  * The readiness facts a `plane up` can actually repair (BAPI-1054).
116
156
  *
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.
157
+ * Both are liveness of a process this machine starts. Everything else
158
+ * `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
159
+ * `unattended_consent`, `unattended_notify_default`, `repository_readiness` is
160
+ * stored server-side state, credentials, or repository configuration, and no
161
+ * amount of starting processes creates any of it. Naming the repairable set
162
+ * here, once, is what keeps the composition from ever attempting a bring-up that
163
+ * could not have helped.
164
+ *
165
+ * This list must NOT grow for the BAPI-1102 prerequisites. A repository that has
166
+ * not consented is `operator_blocked`, and starting a plane for it would leave a
167
+ * running plane behind and change nothing — which is the precise failure the
168
+ * classification exists to prevent.
122
169
  */
123
170
  export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
124
171
  /**
@@ -133,13 +180,51 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
133
180
  * three competing actions has to work out which to do first, and the order here
134
181
  * already encodes that (setup, then config, then credentials).
135
182
  */
136
- export function classifyV2Readiness(readiness) {
137
- const unmet = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
183
+ export function classifyV2Readiness(readiness, options = {}) {
184
+ // BAPI-1102 the explicit `--attended` opt-out makes the unattended-only
185
+ // requirements INAPPLICABLE, not merely tolerated. They authorize unattended
186
+ // operation and automatic merge behavior; an attended run asks for neither, so
187
+ // refusing it for a missing consent flag would block a configuration that is
188
+ // entirely safe (clar-004).
189
+ // BAPI-1102 — the escalation channel is satisfiable per RUN as well as per
190
+ // repository, and no column supplies a repository default today. A
191
+ // `--policy-file` may therefore declare `notify.webhook_url` and satisfy the
192
+ // prerequisite outright, which this layer cannot see: the file stays opaque
193
+ // here and is composed and read by `setup-epic`. Refusing on the repository
194
+ // field alone would refuse every run supplying its own channel — the very form
195
+ // the remedy names — so the requirement is DEFERRED to the composing step,
196
+ // which reads the policy and refuses if it declares none. Nothing is weakened:
197
+ // `setup-epic` gates before the create, and the server re-checks the predicate
198
+ // at approval and probes the URL there.
199
+ const deferred = options.hasPolicyFile
200
+ ? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
201
+ : V2_READINESS_REQUIREMENTS;
202
+ // `--into-base` is the second opt-out, and it is structural rather than
203
+ // declared: it selects the branch-silent composition, whose policy carries no
204
+ // posture and no auto-merge authorization at all. Such a run is not unattended
205
+ // and never becomes so, so holding it to prerequisites that authorize
206
+ // unattended operation would block a configuration these checks have nothing
207
+ // to say about — the same reasoning `--attended` rests on, reached by a
208
+ // different route. See `composeDefaultSetupEpicPolicy`.
209
+ const optedOut = options.attended === true || options.intoBase === true;
210
+ const applicable = optedOut
211
+ ? deferred.filter((req) => req.unattendedOnly !== true)
212
+ : deferred;
213
+ const unmet = applicable.filter((req) => !req.satisfied(readiness));
138
214
  if (unmet.length === 0)
139
215
  return { kind: "green" };
140
216
  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 };
217
+ if (blocker) {
218
+ return {
219
+ kind: "operator_blocked",
220
+ id: blocker.id,
221
+ describe: blocker.describe,
222
+ ...(() => {
223
+ const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
224
+ return remedy ? { remedy } : {};
225
+ })(),
226
+ };
227
+ }
143
228
  return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
144
229
  }
145
230
  export function getDriveEpicUsage() {
@@ -159,12 +244,22 @@ export function getDriveEpicUsage() {
159
244
  "strategy itself is decided by the bootstrap this command delegates to, never",
160
245
  "here.",
161
246
  "",
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.",
247
+ "Posture (v2, BAPI-1102): a run that selects an epic branch is created",
248
+ "UNATTENDED by default it authorizes automatic merge into the epic branch, and",
249
+ "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
250
+ "repository base branch stays human-gated. Pass --attended to opt out. An",
251
+ "unattended run has two one-time repository prerequisites (consent, and a",
252
+ "verified notify webhook default) that readiness lists with their state and that",
253
+ "are refused BY NAME before any run is created.",
254
+ "",
255
+ "One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
256
+ "starts the conductor runtime for you when readiness is missing ONLY the",
257
+ "reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
258
+ "reconciler), creates and approves the run, then starts executor lanes SCOPED to",
259
+ "that run, and starts the dead-man observer last — one invocation, no manual",
260
+ "`plane up` first. A report missing supervisor setup, supervisor configuration,",
261
+ "GitHub App credentials, or an unattended prerequisite is NOT started: those are",
262
+ "operator-owned, and drive-epic exits with that one named reason instead.",
168
263
  "",
169
264
  "Preconditions are validated, never repaired: the current directory must be a Git",
170
265
  "work tree, and the local server port must be free. No worktree is created for",
@@ -185,6 +280,9 @@ export function getDriveEpicUsage() {
185
280
  " repository base branch (the pre-BAPI-1009 behavior).",
186
281
  " Contradictory with --feature-branch; passing both is a",
187
282
  " parse error.",
283
+ " --attended OPT OUT of the unattended default. Forwarded verbatim to",
284
+ " the bootstrap, which owns what it composes; drive-epic",
285
+ " reads it only to skip the unattended prerequisites.",
188
286
  "",
189
287
  "Policy (forwarded verbatim; drive-epic interprets none of it):",
190
288
  " --policy-file <path> JSON file holding the COMPLETE run policy.",
@@ -230,12 +328,20 @@ export function parseDriveEpicArgs(argv) {
230
328
  featureBranch = trimmed;
231
329
  return trimmed;
232
330
  };
331
+ let attended = false;
233
332
  for (let i = 0; i < argv.length; i++) {
234
333
  const arg = argv[i];
235
334
  if (arg === "--into-base") {
236
335
  intoBase = true;
237
336
  continue;
238
337
  }
338
+ // BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
339
+ // exactly one thing: making the unattended readiness prerequisites
340
+ // inapplicable. drive-epic reads no policy.
341
+ if (arg === "--attended") {
342
+ attended = true;
343
+ continue;
344
+ }
239
345
  if (arg === "--replace-policy") {
240
346
  replacePolicy = true;
241
347
  continue;
@@ -330,6 +436,8 @@ export function parseDriveEpicArgs(argv) {
330
436
  ...(repo ? { repo } : {}),
331
437
  ...(featureBranch ? { featureBranch } : {}),
332
438
  ...(intoBase ? { intoBase: true } : {}),
439
+ // Added only when supplied, so the no-flag options object stays byte-identical.
440
+ ...(attended ? { attended: true } : {}),
333
441
  ...(policyFile ? { policyFile } : {}),
334
442
  ...(reviewPolicy ? { reviewPolicy } : {}),
335
443
  ...(replacePolicy ? { replacePolicy: true } : {}),
@@ -343,9 +451,23 @@ export function parseDriveEpicArgs(argv) {
343
451
  * decision. The unknown case is produced by the caller, which is the only place
344
452
  * that can observe a failed read — selection itself never happens by exception
345
453
  * handling, and so never needs to report that it could not decide.
454
+ *
455
+ * BAPI-1102 — the `unattendedOnly` requirements are EXCLUDED from routing, and
456
+ * the exclusion is load-bearing rather than tidy. This function answers "can the
457
+ * v2 engine drive an epic at all?", and the answer does not depend on whether
458
+ * the repository has consented to UNATTENDED operation: an attended v2 run on a
459
+ * non-consenting repository is a perfectly ordinary, fully supported run.
460
+ * Including them would route every such repository to the pilot conductor the
461
+ * moment this ticket deployed — a silent, repository-wide change of conductor for
462
+ * a reason that has nothing to do with the choice, and the exact opposite of
463
+ * AC-3's "nothing changes for an existing run".
464
+ *
465
+ * They still gate the COMPOSED bring-up, through
466
+ * {@link classifyV2Readiness}, which is where an unattended run is actually
467
+ * about to be created.
346
468
  */
347
469
  export function selectConductor(readiness) {
348
- const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
470
+ const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
349
471
  if (missing.length === 0) {
350
472
  return {
351
473
  kind: "selected",
@@ -372,7 +494,7 @@ export function selectConductor(readiness) {
372
494
  * no quoting to get wrong.
373
495
  */
374
496
  export function buildSetupEpicArgv(options) {
375
- const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
497
+ const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
376
498
  return [
377
499
  "--epic-key",
378
500
  epicKey,
@@ -380,6 +502,9 @@ export function buildSetupEpicArgv(options) {
380
502
  ...(repo ? ["--repo", repo] : []),
381
503
  ...(featureBranch ? ["--feature-branch", featureBranch] : []),
382
504
  ...(intoBase ? ["--into-base"] : []),
505
+ // Appended beside the other branch/posture pass-throughs, and after
506
+ // `--into-base` so the forwarded order matches the declaration order above.
507
+ ...(attended ? ["--attended"] : []),
383
508
  ...(policyFile ? ["--policy-file", policyFile] : []),
384
509
  ...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
385
510
  ...(replacePolicy ? ["--replace-policy"] : []),
@@ -596,10 +721,23 @@ export function createDefaultDriveEpicDeps() {
596
721
  return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
597
722
  },
598
723
  readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
599
- runSetupEpic: (argv) => runSetupEpicCli(argv),
724
+ runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
600
725
  readEligibilityArtifact: (path) => readFile(path, "utf8"),
601
726
  runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
602
727
  runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
728
+ launchControlPlane: ({ preflight, executors }) => launchPlaneControlPlane({
729
+ preflight,
730
+ executors,
731
+ // stdout/stderr, not the injected log seams: the plane streams its own
732
+ // member events through these for the plane's whole lifetime, and
733
+ // drive-epic's `log` is a per-message reporter, not a stream sink.
734
+ sinks: {
735
+ stdout: (line) => console.log(line),
736
+ stderr: (line) => console.error(line),
737
+ },
738
+ env: process.env,
739
+ }),
740
+ requestScopedLanes: (args) => requestPlaneScopedLanes(args),
603
741
  cwd: () => process.cwd(),
604
742
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
605
743
  // construction, so nothing has claimed stdout for the protocol transport.
@@ -672,12 +810,22 @@ export async function runDriveEpicCli(argv, overrides = {}) {
672
810
  // the runtime facts is not a "use the other conductor" situation — it is a
673
811
  // plane that has not been started yet.
674
812
  if (planFile) {
675
- const classification = classifyV2Readiness(readiness);
813
+ // BAPI-1102 the explicit `--attended` opt-out makes the unattended
814
+ // prerequisites inapplicable. Both inputs are FLAG checks, not policy
815
+ // reads: whatever a `--policy-file` says stays opaque here, exactly as it
816
+ // always has. Its mere PRESENCE defers the escalation-channel requirement
817
+ // to `setup-epic`, which composes the policy and can actually read it.
818
+ const classification = classifyV2Readiness(readiness, {
819
+ attended: parsed.options.attended === true,
820
+ intoBase: parsed.options.intoBase === true,
821
+ hasPolicyFile: parsed.options.policyFile !== undefined,
822
+ });
676
823
  const setupArgv = buildSetupEpicArgv(parsed.options);
677
824
  // Green: delegate exactly as before. No plane invocation, no preflight,
678
- // and byte-identical argv for the no-flag case.
825
+ // and byte-identical argv for the no-flag case. Only the structured
826
+ // result's exit code is returned, so the public contract is unchanged.
679
827
  if (classification.kind === "green") {
680
- return await deps.runSetupEpic(setupArgv);
828
+ return (await deps.runSetupEpic(setupArgv)).exitCode;
681
829
  }
682
830
  // Operator-blocked: ONE named reason, and nothing is started. `plane up`
683
831
  // cannot create stored supervisor state or resolve GitHub App credentials,
@@ -688,7 +836,11 @@ export async function runDriveEpicCli(argv, overrides = {}) {
688
836
  `(${classification.id}).`,
689
837
  "",
690
838
  "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.",
839
+ // The requirement's OWN remedy when it has one. Telling an operator to
840
+ // run `install-bridge conductor` for a missing consent flag sends them
841
+ // to a command that cannot set it.
842
+ classification.remedy ??
843
+ "Resolve it with `install-bridge conductor`, then run drive-epic again.",
692
844
  ].join("\n");
693
845
  // Named as a fixed reason and nothing else: no conductor is offered here,
694
846
  // because handing an operator a second authority is the hazard this
@@ -716,42 +868,126 @@ export async function runDriveEpicCli(argv, overrides = {}) {
716
868
  deps.errorLog(refusal);
717
869
  return 1;
718
870
  }
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.
871
+ // ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
872
+ //
873
+ // BAPI-1102 reordered this whole block. It used to be `plane up` (which
874
+ // spawned executor lanes) and THEN `setup-epic`, which cannot work in two
875
+ // independent ways: the lanes were spawned with no claim scope, so since
876
+ // BAPI-1026 they exit at startup; and `plane up` stays attached for the
877
+ // plane's lifetime, so the composed flow could never reach `setup-epic` at
878
+ // all. Both are why the BAPI-1061 and BAPI-1085 drivers fell back to
879
+ // hand-written helpers.
880
+ //
881
+ // The new order is the only one that can work: the server has to be up
882
+ // before a run can be created, and the run has to exist before a lane can
883
+ // be scoped to it.
884
+ deps.log(`${epicKey}: the conductor runtime is not live; starting its control plane before creating the run.`);
885
+ // The SAME preflight result is handed to the launch, so the port that was
886
+ // probed is the port that gets launched and the two cannot disagree.
887
+ const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
888
+ if (!controlPlane.ok) {
889
+ // The launch already reported why, in its own words, and it rolls its own
890
+ // startup back. Nothing to add, and no run was created here.
891
+ for (const line of controlPlane.lines)
892
+ deps.errorLog(line);
729
893
  return 1;
730
894
  }
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.
895
+ // Held so the launcher's lifetime promise is never unobserved. It settles
896
+ // when the PLANE comes down, which is long after this command returns —
897
+ // the detached runtime is its own process-group leader and outlives us by
898
+ // design, which is what "the plane is left running" means.
899
+ void controlPlane.lifetime.catch(() => undefined);
900
+ // Readiness is NOT re-read as a gate here, and that has not changed:
901
+ // `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
902
+ // and `executor_jobs` rows tables that are EMPTY on a repository that has
903
+ // never run an epic — so gating on a re-read would hang the one-command
904
+ // flow permanently on exactly the first-time-operator case it exists to
905
+ // serve. A successful bring-up IS the runtime fact.
906
+ // ---- PHASE TWO, part one: create the run ------------------------------
907
+ const setup = await deps.runSetupEpic(setupArgv);
908
+ if (setup.exitCode !== 0) {
909
+ // The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
910
+ // was spawned — there is nothing to claim a job that may not exist.
911
+ // Winding down automatically would stop the very run `setup-epic` may
912
+ // have just created, because `plane down` stops the manifest-bound run
913
+ // before terminating processes.
746
914
  deps.errorLog([
747
915
  "",
748
- `${epicKey}: the conductor runtime was started by this invocation and is STILL RUNNING.`,
916
+ `${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
917
+ "No executor lane was spawned, so nothing is claiming jobs.",
749
918
  "It was not wound down automatically, because doing so would stop a run that may",
750
919
  "already have been created and approved. Inspect it with `plane status`, and wind it",
751
920
  "down with `plane down` once you have decided what to do with the run.",
752
921
  ].join("\n"));
922
+ return setup.exitCode;
753
923
  }
754
- return setupExit;
924
+ if (setup.epicRunId === undefined) {
925
+ // Exit zero with no run id is an ORCHESTRATION failure, not a success:
926
+ // there is no authoritative identity to scope lanes to, and the one thing
927
+ // this reordering exists to prevent is a lane started against a run
928
+ // nobody named. Guessing — a repository-wide lookup, say — could scope
929
+ // lanes to a DIFFERENT run on the same repository.
930
+ deps.errorLog([
931
+ "",
932
+ `${epicKey}: setup reported success but returned no epic run id, so no executor lane`,
933
+ "could be scoped to this run. NO LANE WAS SPAWNED. The control plane is still",
934
+ "running: inspect it with `plane status` and wind it down with `plane down`.",
935
+ ].join("\n"));
936
+ return 1;
937
+ }
938
+ // ---- PHASE TWO, part two: scoped lanes, then the observer -------------
939
+ const laneRequest = await deps.requestScopedLanes({
940
+ repoRoot,
941
+ planeId: controlPlane.planeId,
942
+ epicRunId: setup.epicRunId,
943
+ claimScope: { kind: "epic-runs", epicRunIds: [setup.epicRunId] },
944
+ });
945
+ if (!laneRequest.ok) {
946
+ deps.errorLog([
947
+ "",
948
+ `${epicKey}: the run was created and approved, but executor lanes could not be`,
949
+ `requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
950
+ laneRequest.reason === "run-conflict"
951
+ ? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
952
+ : "Inspect it with `plane status`, and wind it down with `plane down`.",
953
+ "The run itself is untouched and still committed server-side.",
954
+ ].join("\n"));
955
+ return 1;
956
+ }
957
+ const planeReady = await controlPlane.awaitPlaneReady();
958
+ if (!planeReady.ok) {
959
+ // No rollback of the RUN, deliberately. A lane that failed to start says
960
+ // nothing about whether the run is valid, and stopping a committed,
961
+ // approved run because a local process died would destroy state the
962
+ // operator may well want to retry into.
963
+ for (const line of planeReady.lines)
964
+ deps.errorLog(line);
965
+ deps.errorLog([
966
+ "",
967
+ `${epicKey}: no executor lane became ready, and the dead-man observer was NOT started.`,
968
+ "The control plane and the committed run are both intact and were not rolled back:",
969
+ "the runtime stops the lane cohort it started and keeps supervising the server and",
970
+ "reconciler, so nothing is claiming jobs and the run keeps its server-side state.",
971
+ "Read the per-lane logs under .bridge/plane/, then wind the plane down with",
972
+ "`plane down` and re-run this command once the cause is fixed. A plane cannot be",
973
+ "asked for lanes a second time: the runtime waits for that request exactly once.",
974
+ ].join("\n"));
975
+ return 1;
976
+ }
977
+ // Plane readiness is reported SEPARATELY from run dispatchability, and the
978
+ // separation is the point: every process is up, but the reconciler still
979
+ // holds dispatch while a feature branch's index scope finishes preparing.
980
+ // An operator told "ready" who then sees no work start would go looking for
981
+ // a broken plane that is working perfectly.
982
+ deps.log([
983
+ "",
984
+ `${epicKey}: the plane is READY — control plane, scoped executor lane(s) for run ` +
985
+ `${setup.epicRunId}, and the dead-man observer are all up.`,
986
+ "That reports PROCESSES. If this run cuts an epic branch, the reconciler holds ticket",
987
+ "dispatch until its index scope finishes preparing; `plane status` and the run's own",
988
+ "status report that separately.",
989
+ ].join("\n"));
990
+ return 0;
755
991
  }
756
992
  const handoff = renderConductorHandoff(selection, epicKey);
757
993
  const text = handoff.lines.join("\n");