@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -34,6 +34,11 @@
34
34
  * response means we do not know which authority owns this epic, and guessing is
35
35
  * the failure mode.
36
36
  *
37
+ * One narrow exception (BAPI-1153): with `--plan-file`, when `BAPI_BASE_URL`
38
+ * names the local plane's own endpoint and a TCP probe of that endpoint is
39
+ * actively REFUSED, the failed read is not unknown — it is "no server yet", and
40
+ * drive-epic cold-starts the plane. Every other failed read still escalates.
41
+ *
37
42
  * ## Consolidation day
38
43
  *
39
44
  * Two conductors is a transitional state; one is to be eliminated at the end of
@@ -53,15 +58,21 @@
53
58
  */
54
59
  import { readFile } from "node:fs/promises";
55
60
  import { dirname, join } from "node:path";
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";
59
- import { PLANE_SERVER_PORT_ENV_VAR } from "./plane/types.js";
61
+ import { ConductorBridgeApiError, resolveConductorBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
62
+ import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
63
+ import { runSetupEpicWorkflow, } from "./setup-epic.js";
64
+ import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
65
+ import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
66
+ import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
67
+ import { readPlaneManifest } from "./plane/manifest.js";
68
+ import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
69
+ import { resolveMemberInstanceId } from "./plane/supervisor.js";
70
+ import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
60
71
  import { validateBranchName } from "./base-ref.js";
61
72
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
62
73
  // the printed invocation interpolates it rather than repeating it.
63
74
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
64
- /** Jira epic key shape. Matches `conduct-epic`'s positional rule exactly. */
75
+ /** Jira epic key shape. Matches `implement-epic`'s positional rule exactly. */
65
76
  const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
66
77
  /** The two conductor paths. Derived types below come from this constant. */
67
78
  export const DRIVE_EPIC_CONDUCTORS = ["v2", "pilot"];
@@ -110,15 +121,66 @@ export const V2_READINESS_REQUIREMENTS = [
110
121
  describe: "an executor provisioned and reporting ready",
111
122
  satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
112
123
  },
124
+ // BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
125
+ // refuses them, so readiness and the refusal name the same first blocker.
126
+ //
127
+ // Each reads the SERVER's `unattended` block and re-derives nothing. A server
128
+ // that does not report the block (older than BAPI-1102) leaves every one of
129
+ // them UNSATISFIED: "could not be read" is not "holds", and these authorize an
130
+ // unattended merge.
131
+ //
132
+ // The FIRST of them therefore carries the older-server remedy too, because it
133
+ // is the one an unreported block always blocks on, and telling that operator
134
+ // to set a consent flag would send them to fix something that may already be
135
+ // set — the server simply never said (BAPI-1102 review).
136
+ {
137
+ id: "unattended_consent",
138
+ describe: "repository consent to unattended conductor runs",
139
+ unattendedOnly: true,
140
+ // No fixed `remedy`: this requirement's action always depends on WHY.
141
+ remedyFor: (r) => r.unattended === null || r.unattended === undefined
142
+ ? "This Bridge API deploy does not report a repository's unattended " +
143
+ "prerequisites at all, so none of them could be confirmed. Upgrade the " +
144
+ "deploy, or pass --attended."
145
+ : "Set `unattended_epic_implementer_allowed` for this repository " +
146
+ "(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
147
+ satisfied: (r) => r.unattended?.conductor_allowed === true,
148
+ },
149
+ {
150
+ id: "unattended_notify_default",
151
+ describe: "a verified notify webhook default for escalation",
152
+ unattendedOnly: true,
153
+ remedy: "Supply a --policy-file declaring notify.webhook_url, or pass --attended. " +
154
+ "notify.local_sink does not satisfy this.",
155
+ satisfied: (r) => r.unattended?.notify_webhook_default_declared === true,
156
+ },
157
+ {
158
+ id: "repository_readiness",
159
+ describe: "confirmed review and conductor-CI workflows on the default branch",
160
+ unattendedOnly: true,
161
+ // S5 seam: `install-bridge conductor` is the NESTED install-time surface
162
+ // (BAPI-679), a different command from the top-level `conductor` ->
163
+ // `epic-implementer` rename in S4/BAPI-1080. It is intentionally outside
164
+ // S4 and every remedy string below that names it is left unchanged.
165
+ remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
166
+ satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
167
+ },
113
168
  ];
114
169
  /**
115
170
  * The readiness facts a `plane up` can actually repair (BAPI-1054).
116
171
  *
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.
172
+ * Both are liveness of a process this machine starts. Everything else
173
+ * `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
174
+ * `unattended_consent`, `unattended_notify_default`, `repository_readiness` is
175
+ * stored server-side state, credentials, or repository configuration, and no
176
+ * amount of starting processes creates any of it. Naming the repairable set
177
+ * here, once, is what keeps the composition from ever attempting a bring-up that
178
+ * could not have helped.
179
+ *
180
+ * This list must NOT grow for the BAPI-1102 prerequisites. A repository that has
181
+ * not consented is `operator_blocked`, and starting a plane for it would leave a
182
+ * running plane behind and change nothing — which is the precise failure the
183
+ * classification exists to prevent.
122
184
  */
123
185
  export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
124
186
  /**
@@ -133,13 +195,51 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
133
195
  * three competing actions has to work out which to do first, and the order here
134
196
  * already encodes that (setup, then config, then credentials).
135
197
  */
136
- export function classifyV2Readiness(readiness) {
137
- const unmet = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
198
+ export function classifyV2Readiness(readiness, options = {}) {
199
+ // BAPI-1102 the explicit `--attended` opt-out makes the unattended-only
200
+ // requirements INAPPLICABLE, not merely tolerated. They authorize unattended
201
+ // operation and automatic merge behavior; an attended run asks for neither, so
202
+ // refusing it for a missing consent flag would block a configuration that is
203
+ // entirely safe (clar-004).
204
+ // BAPI-1102 — the escalation channel is satisfiable per RUN as well as per
205
+ // repository, and no column supplies a repository default today. A
206
+ // `--policy-file` may therefore declare `notify.webhook_url` and satisfy the
207
+ // prerequisite outright, which this layer cannot see: the file stays opaque
208
+ // here and is composed and read by `setup-epic`. Refusing on the repository
209
+ // field alone would refuse every run supplying its own channel — the very form
210
+ // the remedy names — so the requirement is DEFERRED to the composing step,
211
+ // which reads the policy and refuses if it declares none. Nothing is weakened:
212
+ // `setup-epic` gates before the create, and the server re-checks the predicate
213
+ // at approval and probes the URL there.
214
+ const deferred = options.hasPolicyFile
215
+ ? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
216
+ : V2_READINESS_REQUIREMENTS;
217
+ // `--into-base` is the second opt-out, and it is structural rather than
218
+ // declared: it selects the branch-silent composition, whose policy carries no
219
+ // posture and no auto-merge authorization at all. Such a run is not unattended
220
+ // and never becomes so, so holding it to prerequisites that authorize
221
+ // unattended operation would block a configuration these checks have nothing
222
+ // to say about — the same reasoning `--attended` rests on, reached by a
223
+ // different route. See `composeDefaultSetupEpicPolicy`.
224
+ const optedOut = options.attended === true || options.intoBase === true;
225
+ const applicable = optedOut
226
+ ? deferred.filter((req) => req.unattendedOnly !== true)
227
+ : deferred;
228
+ const unmet = applicable.filter((req) => !req.satisfied(readiness));
138
229
  if (unmet.length === 0)
139
230
  return { kind: "green" };
140
231
  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 };
232
+ if (blocker) {
233
+ return {
234
+ kind: "operator_blocked",
235
+ id: blocker.id,
236
+ describe: blocker.describe,
237
+ ...(() => {
238
+ const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
239
+ return remedy ? { remedy } : {};
240
+ })(),
241
+ };
242
+ }
143
243
  return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
144
244
  }
145
245
  export function getDriveEpicUsage() {
@@ -159,20 +259,40 @@ export function getDriveEpicUsage() {
159
259
  "strategy itself is decided by the bootstrap this command delegates to, never",
160
260
  "here.",
161
261
  "",
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.",
262
+ "Posture (v2, BAPI-1102): a run that selects an epic branch is created",
263
+ "UNATTENDED by default it authorizes automatic merge into the epic branch, and",
264
+ "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
265
+ "repository base branch stays human-gated. Pass --attended to opt out. An",
266
+ "unattended run has two one-time repository prerequisites (consent, and a",
267
+ "verified notify webhook default) that readiness lists with their state and that",
268
+ "are refused BY NAME before any run is created.",
168
269
  "",
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.`,
270
+ "One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
271
+ "starts the conductor runtime for you when readiness is missing ONLY the",
272
+ "reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
273
+ "reconciler), creates and approves the run, then starts executor lanes SCOPED to",
274
+ "that run, and starts the dead-man observer last — one invocation, no manual",
275
+ "`plane up` first. A report missing supervisor setup, supervisor configuration,",
276
+ "GitHub App credentials, or an unattended prerequisite is NOT started: those are",
277
+ "operator-owned, and drive-epic exits with that one named reason instead.",
278
+ "",
279
+ "The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
280
+ `local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
281
+ "drive-epic handles the port one of three ways, and never asks you to choose:",
282
+ " - cold start: nothing listens there — it brings the plane up, then creates",
283
+ " the run;",
284
+ " - attach: this repository's own healthy plane is there — it creates the run",
285
+ " against that plane and starts NO second server or reconciler;",
286
+ " - refusal: anything else holds the port (another listener, a manifest for a",
287
+ " different repository, an unreadable manifest, a plane that cannot take a",
288
+ " run) — one named reason, and nothing is started.",
289
+ "One epic per plane: a plane already bound to a different or finished run is",
290
+ "refused as plane-already-bound. Executor lanes are always scoped to the one",
291
+ "run with --epic-run-id, on every path.",
173
292
  "",
174
- "Manual fallback (still supported): run `plane up` yourself, then rerun this",
175
- "command with the same --plan-file against the now-live plane.",
293
+ "Preconditions are validated, never repaired: the current directory must be a Git",
294
+ "work tree. No worktree is created for you and no replacement port is chosen for",
295
+ "you — each failure names the fix. Fix it and rerun.",
176
296
  "",
177
297
  "Options:",
178
298
  " --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
@@ -185,6 +305,9 @@ export function getDriveEpicUsage() {
185
305
  " repository base branch (the pre-BAPI-1009 behavior).",
186
306
  " Contradictory with --feature-branch; passing both is a",
187
307
  " parse error.",
308
+ " --attended OPT OUT of the unattended default. Forwarded verbatim to",
309
+ " the bootstrap, which owns what it composes; drive-epic",
310
+ " reads it only to skip the unattended prerequisites.",
188
311
  "",
189
312
  "Policy (forwarded verbatim; drive-epic interprets none of it):",
190
313
  " --policy-file <path> JSON file holding the COMPLETE run policy.",
@@ -230,12 +353,20 @@ export function parseDriveEpicArgs(argv) {
230
353
  featureBranch = trimmed;
231
354
  return trimmed;
232
355
  };
356
+ let attended = false;
233
357
  for (let i = 0; i < argv.length; i++) {
234
358
  const arg = argv[i];
235
359
  if (arg === "--into-base") {
236
360
  intoBase = true;
237
361
  continue;
238
362
  }
363
+ // BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
364
+ // exactly one thing: making the unattended readiness prerequisites
365
+ // inapplicable. drive-epic reads no policy.
366
+ if (arg === "--attended") {
367
+ attended = true;
368
+ continue;
369
+ }
239
370
  if (arg === "--replace-policy") {
240
371
  replacePolicy = true;
241
372
  continue;
@@ -330,6 +461,8 @@ export function parseDriveEpicArgs(argv) {
330
461
  ...(repo ? { repo } : {}),
331
462
  ...(featureBranch ? { featureBranch } : {}),
332
463
  ...(intoBase ? { intoBase: true } : {}),
464
+ // Added only when supplied, so the no-flag options object stays byte-identical.
465
+ ...(attended ? { attended: true } : {}),
333
466
  ...(policyFile ? { policyFile } : {}),
334
467
  ...(reviewPolicy ? { reviewPolicy } : {}),
335
468
  ...(replacePolicy ? { replacePolicy: true } : {}),
@@ -343,9 +476,23 @@ export function parseDriveEpicArgs(argv) {
343
476
  * decision. The unknown case is produced by the caller, which is the only place
344
477
  * that can observe a failed read — selection itself never happens by exception
345
478
  * handling, and so never needs to report that it could not decide.
479
+ *
480
+ * BAPI-1102 — the `unattendedOnly` requirements are EXCLUDED from routing, and
481
+ * the exclusion is load-bearing rather than tidy. This function answers "can the
482
+ * v2 engine drive an epic at all?", and the answer does not depend on whether
483
+ * the repository has consented to UNATTENDED operation: an attended v2 run on a
484
+ * non-consenting repository is a perfectly ordinary, fully supported run.
485
+ * Including them would route every such repository to the pilot conductor the
486
+ * moment this ticket deployed — a silent, repository-wide change of conductor for
487
+ * a reason that has nothing to do with the choice, and the exact opposite of
488
+ * AC-3's "nothing changes for an existing run".
489
+ *
490
+ * They still gate the COMPOSED bring-up, through
491
+ * {@link classifyV2Readiness}, which is where an unattended run is actually
492
+ * about to be created.
346
493
  */
347
494
  export function selectConductor(readiness) {
348
- const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
495
+ const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
349
496
  if (missing.length === 0) {
350
497
  return {
351
498
  kind: "selected",
@@ -372,7 +519,7 @@ export function selectConductor(readiness) {
372
519
  * no quoting to get wrong.
373
520
  */
374
521
  export function buildSetupEpicArgv(options) {
375
- const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
522
+ const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
376
523
  return [
377
524
  "--epic-key",
378
525
  epicKey,
@@ -380,6 +527,9 @@ export function buildSetupEpicArgv(options) {
380
527
  ...(repo ? ["--repo", repo] : []),
381
528
  ...(featureBranch ? ["--feature-branch", featureBranch] : []),
382
529
  ...(intoBase ? ["--into-base"] : []),
530
+ // Appended beside the other branch/posture pass-throughs, and after
531
+ // `--into-base` so the forwarded order matches the declaration order above.
532
+ ...(attended ? ["--attended"] : []),
383
533
  ...(policyFile ? ["--policy-file", policyFile] : []),
384
534
  ...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
385
535
  ...(replacePolicy ? ["--replace-policy"] : []),
@@ -412,7 +562,7 @@ export function renderConductorHandoff(selection, epicKey) {
412
562
  "",
413
563
  ` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
414
564
  " sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
415
- " If the epic's tickets already exist, run emit-conductor-bundle",
565
+ " If the epic's tickets already exist, run emit-epic-implementer-bundle",
416
566
  " finalize instead — it resolves the placeholder keys in an existing",
417
567
  " sidecar and attaches touched_files.",
418
568
  "",
@@ -581,26 +731,134 @@ export const DRIVE_EPIC_PRECONDITION_CHECKS = [
581
731
  * preflight happened to append them, so the reason an operator sees for a given
582
732
  * pair of failures is stable.
583
733
  */
584
- export function firstPreconditionRefusal(preflight) {
734
+ export function firstPreconditionRefusal(preflight, options = {}) {
585
735
  for (const check of DRIVE_EPIC_PRECONDITION_CHECKS) {
736
+ // BAPI-1153: an occupied port is not a blocker when the occupant is this
737
+ // repository's own attachable plane — attaching is exactly how the port gets
738
+ // used. Every other occupant still blocks, and `plane up`'s own diagnostic is
739
+ // unchanged (a live plane still refuses a second `plane up`).
740
+ if (check === "server-port" && options.attachable === true)
741
+ continue;
586
742
  const found = preflight.diagnostics.find((d) => d.check === check && d.severity === "blocking");
587
743
  if (found)
588
744
  return found;
589
745
  }
590
746
  return null;
591
747
  }
748
+ // ---------------------------------------------------------------------------
749
+ // BAPI-1153 — cold start, attach, or one named refusal
750
+ // ---------------------------------------------------------------------------
751
+ /**
752
+ * The refusals drive-epic itself names on the cold-start and attach paths.
753
+ *
754
+ * The bring-up facts refuse under their OWN names (`second-reconciler-detected`
755
+ * and the rest, from `conductor/bring-up-facts.ts`), and an occupied port keeps
756
+ * the existing `server-port` refusal; these two are the only reasons that exist
757
+ * nowhere else.
758
+ */
759
+ export const DRIVE_EPIC_PLANE_REFUSALS = [
760
+ /** The plane is bound to a run that is not a live run of this epic (R-7). */
761
+ "plane-already-bound",
762
+ /** The plane's own reconciler is not confirmed as the fresh one. */
763
+ "plane-reconciler-unconfirmed",
764
+ ];
765
+ /** Loopback spellings that name the plane's `127.0.0.1` host. */
766
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
767
+ /**
768
+ * Does `baseUrl` name the plane's own loopback endpoint? Pure.
769
+ *
770
+ * Cold start and attach are decisions about THE PLANE, so they only make sense
771
+ * when the Bridge API the command talks to IS the plane's server. The conductor
772
+ * default base URL is production; a readiness failure against it says nothing
773
+ * about a local port, and must keep escalating exactly as before. Loopback
774
+ * spellings are equivalent (`localhost` and `127.0.0.1` reach the same socket);
775
+ * the port must match exactly.
776
+ */
777
+ export function baseUrlNamesPlaneEndpoint(baseUrl, endpoint) {
778
+ let url;
779
+ try {
780
+ url = new URL(baseUrl);
781
+ }
782
+ catch {
783
+ return false;
784
+ }
785
+ if (url.protocol !== "http:")
786
+ return false;
787
+ const port = url.port === "" ? 80 : Number(url.port);
788
+ return LOOPBACK_HOSTNAMES.has(url.hostname) && port === endpoint.port;
789
+ }
790
+ /**
791
+ * Rebuild the identity the plane's reconciler publishes its heartbeat under. Pure.
792
+ *
793
+ * The reconciler is the manifest's `worker` member; its identity is
794
+ * `<hostname>:<pid>` (or `DYNO`), rebuilt by the supervisor's own
795
+ * `resolveMemberInstanceId` so the string cannot drift from the one the
796
+ * readiness gate used at bring-up. `undefined` when the manifest records no live
797
+ * worker pid — an unidentifiable member is never guessed at.
798
+ */
799
+ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
800
+ const worker = manifest.members.find((member) => member.name === "worker");
801
+ if (worker === undefined || worker.pid === null)
802
+ return undefined;
803
+ const memberEnv = {};
804
+ if (typeof env.DYNO === "string")
805
+ memberEnv.DYNO = env.DYNO;
806
+ return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
807
+ }
808
+ /** Terminal `epic_runs.status` values; every other status is a live run. */
809
+ const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
592
810
  export function createDefaultDriveEpicDeps() {
593
811
  return {
594
- resolveAccess: async (repo) => {
595
- const result = await resolveConductorBridgeApiAccess(repo ? { repoName: repo } : {});
812
+ resolveAccess: async (repo, cwd) => {
813
+ const result = await resolveConductorBridgeApiAccess({
814
+ ...(repo ? { repoName: repo } : {}),
815
+ ...(cwd ? { cwd } : {}),
816
+ });
596
817
  return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
597
818
  },
598
819
  readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
599
- runSetupEpic: (argv) => runSetupEpicCli(argv),
820
+ runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
600
821
  readEligibilityArtifact: (path) => readFile(path, "utf8"),
601
822
  runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
602
823
  runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
824
+ launchControlPlane: ({ preflight, executors }) => launchPlaneControlPlane({
825
+ preflight,
826
+ executors,
827
+ // stdout/stderr, not the injected log seams: the plane streams its own
828
+ // member events through these for the plane's whole lifetime, and
829
+ // drive-epic's `log` is a per-message reporter, not a stream sink.
830
+ sinks: {
831
+ stdout: (line) => console.log(line),
832
+ stderr: (line) => console.error(line),
833
+ },
834
+ env: process.env,
835
+ }),
836
+ requestScopedLanes: (args) => requestPlaneScopedLanes(args),
603
837
  cwd: () => process.cwd(),
838
+ resolvePlaneEndpoint: () => resolvePlaneServerEndpoint(process.env),
839
+ probePlanePort: (host, port) => probeTcpPort(host, port, PLANE_PORT_PROBE_TIMEOUT_MS),
840
+ readRunState: async (access, epicRunId) => {
841
+ const state = await fetchEpicRunState(access, epicRunId, globalThis.fetch);
842
+ const run = state.epic_run;
843
+ if (typeof run?.status !== "string" || typeof run?.epic_key !== "string") {
844
+ throw new ConductorBridgeApiError("invalid-input");
845
+ }
846
+ return { status: run.status, epicKey: run.epic_key };
847
+ },
848
+ readPlaneReconcilerState: async ({ access, manifest }) => {
849
+ const instanceId = resolvePlaneReconcilerInstanceId(manifest, process.env, resolveHostname);
850
+ if (instanceId === undefined)
851
+ return "unidentifiable";
852
+ const observation = await probeAutomationHealth({
853
+ baseUrl: access.baseUrl,
854
+ repoName: access.repoName,
855
+ apiKey: access.apiKey,
856
+ component: "reconciler",
857
+ instanceId,
858
+ });
859
+ return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
860
+ },
861
+ readPlaneManifest: (repoRoot) => readPlaneManifest(repoRoot, createPlaneFsDeps()),
604
862
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
605
863
  // construction, so nothing has claimed stdout for the protocol transport.
606
864
  log: (message) => console.log(message),
@@ -633,12 +891,32 @@ export async function runDriveEpicCli(argv, overrides = {}) {
633
891
  if (!accessResult.ok) {
634
892
  return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
635
893
  }
636
- // --- Read readiness. Any failure here is UNKNOWN. ------------------------
894
+ // --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
637
895
  let readiness;
638
896
  try {
639
897
  readiness = await deps.readReadiness(accessResult.access);
640
898
  }
641
899
  catch (err) {
900
+ // BAPI-1153 — the cold-start case. With a plan file, a readiness read that
901
+ // failed at the TRANSPORT against the plane's own endpoint, on a port that
902
+ // actively refuses connections, is not "unknown": it is "no server yet".
903
+ // Everything else — a non-plane base URL, an HTTP or auth or shape error, a
904
+ // timeout, any other probe result — keeps today's escalation byte for byte,
905
+ // because there unknown is still fatal (R14).
906
+ if (planFile !== undefined && err instanceof ConductorBridgeApiError && err.kind === "network") {
907
+ const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
908
+ if (endpoint !== null) {
909
+ const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
910
+ if (probe.kind === "refused") {
911
+ return coldStartPlane({
912
+ deps,
913
+ epicKey,
914
+ options: parsed.options,
915
+ access: accessResult.access,
916
+ });
917
+ }
918
+ }
919
+ }
642
920
  return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
643
921
  }
644
922
  // --- Route. Exactly one path from here on. -------------------------------
@@ -649,19 +927,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
649
927
  // artifact, bad JSON, malformed/inconsistent shape, an unsafe rendered
650
928
  // value — silently yields no advisory and never affects routing, the
651
929
  // 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
- }
930
+ if (planFile)
931
+ await surfaceContainmentAdvisory(deps, planFile);
665
932
  // Decide delegation BEFORE rendering. The v2 branch's text is specifically
666
933
  // about a missing plan file, and rendering it on the path that has one
667
934
  // would produce a message contradicting what is about to happen.
@@ -672,12 +939,22 @@ export async function runDriveEpicCli(argv, overrides = {}) {
672
939
  // the runtime facts is not a "use the other conductor" situation — it is a
673
940
  // plane that has not been started yet.
674
941
  if (planFile) {
675
- const classification = classifyV2Readiness(readiness);
942
+ // BAPI-1102 the explicit `--attended` opt-out makes the unattended
943
+ // prerequisites inapplicable. Both inputs are FLAG checks, not policy
944
+ // reads: whatever a `--policy-file` says stays opaque here, exactly as it
945
+ // always has. Its mere PRESENCE defers the escalation-channel requirement
946
+ // to `setup-epic`, which composes the policy and can actually read it.
947
+ const classification = classifyV2Readiness(readiness, {
948
+ attended: parsed.options.attended === true,
949
+ intoBase: parsed.options.intoBase === true,
950
+ hasPolicyFile: parsed.options.policyFile !== undefined,
951
+ });
676
952
  const setupArgv = buildSetupEpicArgv(parsed.options);
677
953
  // Green: delegate exactly as before. No plane invocation, no preflight,
678
- // and byte-identical argv for the no-flag case.
954
+ // and byte-identical argv for the no-flag case. Only the structured
955
+ // result's exit code is returned, so the public contract is unchanged.
679
956
  if (classification.kind === "green") {
680
- return await deps.runSetupEpic(setupArgv);
957
+ return (await deps.runSetupEpic(setupArgv)).exitCode;
681
958
  }
682
959
  // Operator-blocked: ONE named reason, and nothing is started. `plane up`
683
960
  // cannot create stored supervisor state or resolve GitHub App credentials,
@@ -688,7 +965,11 @@ export async function runDriveEpicCli(argv, overrides = {}) {
688
965
  `(${classification.id}).`,
689
966
  "",
690
967
  "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.",
968
+ // The requirement's OWN remedy when it has one. Telling an operator to
969
+ // run `install-bridge conductor` for a missing consent flag sends them
970
+ // to a command that cannot set it.
971
+ classification.remedy ??
972
+ "Resolve it with `install-bridge conductor`, then run drive-epic again.",
692
973
  ].join("\n");
693
974
  // Named as a fixed reason and nothing else: no conductor is offered here,
694
975
  // because handing an operator a second authority is the hazard this
@@ -700,58 +981,85 @@ export async function runDriveEpicCli(argv, overrides = {}) {
700
981
  // Runtime-only: this invocation composes bring-up with run creation.
701
982
  const repoRoot = deps.cwd();
702
983
  const preflight = await deps.runPlanePreflight(repoRoot);
984
+ // BAPI-1153 — ATTACH. A port serving this repository's own live, healthy
985
+ // plane is not a blocker when the Bridge API this command talks to IS that
986
+ // plane: create/approve/cut against it and request a run-scoped lane — no
987
+ // second server, no second reconciler. The classification is the one the
988
+ // preflight made from its single manifest read; nothing is re-read here.
989
+ const existingPlane = preflight.observations?.existingPlane;
990
+ const attachManifest = existingPlane?.kind === "attachable" &&
991
+ preflight.observations?.serverPort === "connected" &&
992
+ planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
993
+ ? existingPlane.manifest
994
+ : null;
703
995
  // Composition preconditions FIRST, and only these two. Preflight is
704
996
  // read-only, so a refusal here provably created no manifest, spawned no
705
997
  // process, and issued no run-creating request — AC-1's "no partial run"
706
998
  // holds structurally rather than by cleanup.
707
- const precondition = firstPreconditionRefusal(preflight);
999
+ const precondition = firstPreconditionRefusal(preflight, {
1000
+ attachable: attachManifest !== null,
1001
+ });
708
1002
  if (precondition) {
709
- const refusal = [
1003
+ const lines = [
710
1004
  `${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
711
1005
  formatPlaneDiagnostic(precondition),
712
- "",
713
- "Nothing was started. Fix the item above and run drive-epic again.",
714
- ].join("\n");
1006
+ ];
1007
+ // BAPI-1153: an occupied port names WHICH kind of occupant it is and its
1008
+ // one fix, still inside this single refusal.
1009
+ if (precondition.check === "server-port") {
1010
+ const occupant = describePlanePortOccupant(existingPlane);
1011
+ lines.push(` Occupant: ${occupant.classification} — ${occupant.remediation}`);
1012
+ }
1013
+ lines.push("", "Nothing was started. Fix the item above and run drive-epic again.");
1014
+ const refusal = lines.join("\n");
715
1015
  assertSingleConductorInvocation(refusal);
716
1016
  deps.errorLog(refusal);
717
1017
  return 1;
718
1018
  }
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;
1019
+ if (attachManifest !== null) {
1020
+ return attachToPlane({ deps, epicKey, options: parsed.options, access: accessResult.access }, attachManifest, readiness);
730
1021
  }
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"));
1022
+ // ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
1023
+ //
1024
+ // BAPI-1102 reordered this whole block. It used to be `plane up` (which
1025
+ // spawned executor lanes) and THEN `setup-epic`, which cannot work in two
1026
+ // independent ways: the lanes were spawned with no claim scope, so since
1027
+ // BAPI-1026 they exit at startup; and `plane up` stays attached for the
1028
+ // plane's lifetime, so the composed flow could never reach `setup-epic` at
1029
+ // all. Both are why the BAPI-1061 and BAPI-1085 drivers fell back to
1030
+ // hand-written helpers.
1031
+ //
1032
+ // The new order is the only one that can work: the server has to be up
1033
+ // before a run can be created, and the run has to exist before a lane can
1034
+ // be scoped to it.
1035
+ deps.log(`${epicKey}: the conductor runtime is not live; starting its control plane before creating the run.`);
1036
+ // The SAME preflight result is handed to the launch, so the port that was
1037
+ // probed is the port that gets launched and the two cannot disagree.
1038
+ const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1039
+ if (!controlPlane.ok) {
1040
+ // The launch already reported why, in its own words, and it rolls its own
1041
+ // startup back. Nothing to add, and no run was created here.
1042
+ for (const line of controlPlane.lines)
1043
+ deps.errorLog(line);
1044
+ return 1;
753
1045
  }
754
- return setupExit;
1046
+ // Held so the launcher's lifetime promise is never unobserved. It settles
1047
+ // when the PLANE comes down, which is long after this command returns —
1048
+ // the detached runtime is its own process-group leader and outlives us by
1049
+ // design, which is what "the plane is left running" means.
1050
+ void controlPlane.lifetime.catch(() => undefined);
1051
+ // Readiness is NOT re-read as a gate here, and that has not changed:
1052
+ // `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
1053
+ // and `executor_jobs` rows — tables that are EMPTY on a repository that has
1054
+ // never run an epic — so gating on a re-read would hang the one-command
1055
+ // flow permanently on exactly the first-time-operator case it exists to
1056
+ // serve. A successful bring-up IS the runtime fact.
1057
+ return createRunAndRequestLanes({ deps, epicKey, options: parsed.options, access: accessResult.access }, {
1058
+ origin: "launched",
1059
+ repoRoot,
1060
+ planeId: controlPlane.planeId,
1061
+ awaitPlaneReady: controlPlane.awaitPlaneReady,
1062
+ });
755
1063
  }
756
1064
  const handoff = renderConductorHandoff(selection, epicKey);
757
1065
  const text = handoff.lines.join("\n");
@@ -767,6 +1075,28 @@ export async function runDriveEpicCli(argv, overrides = {}) {
767
1075
  return 1;
768
1076
  }
769
1077
  }
1078
+ /**
1079
+ * BAPI-1022: surface any containment-hazard advisory before dispatch or handoff.
1080
+ *
1081
+ * Read only when a plan file was supplied (the sidecar has no other resolvable
1082
+ * location). Fail-open: any failure — missing artifact, bad JSON,
1083
+ * malformed/inconsistent shape, an unsafe rendered value — silently yields no
1084
+ * advisory and never affects routing, the delegated argv, or the exit code.
1085
+ * One helper, so the cold-start path (BAPI-1153) cannot drift from the rest.
1086
+ */
1087
+ async function surfaceContainmentAdvisory(deps, planFile) {
1088
+ try {
1089
+ const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
1090
+ if (advisory) {
1091
+ const rendered = renderContainmentEligibilityAdvisory(advisory);
1092
+ if (rendered)
1093
+ deps.log(rendered);
1094
+ }
1095
+ }
1096
+ catch {
1097
+ // Fail open — a containment advisory is never allowed to block dispatch.
1098
+ }
1099
+ }
770
1100
  /**
771
1101
  * The escalation branch. Prints NO conductor invocation at all — naming a
772
1102
  * fallback here is the dual-authority hazard, and an operator who does not know
@@ -785,3 +1115,335 @@ function escalate(deps, epicKey, reason) {
785
1115
  deps.errorLog(text);
786
1116
  return 1;
787
1117
  }
1118
+ /** Emit one refusal, guarded so it can never name a second conductor. */
1119
+ function refuse(deps, lines) {
1120
+ const text = lines.join("\n");
1121
+ assertSingleConductorInvocation(text);
1122
+ deps.errorLog(text);
1123
+ return 1;
1124
+ }
1125
+ /**
1126
+ * The plane endpoint, when `baseUrl` names it; otherwise `null`.
1127
+ *
1128
+ * `null` for an unresolvable `BAPI_PLANE_PORT` too: with no endpoint there is
1129
+ * no plane to cold-start or attach to, and the caller's existing behavior
1130
+ * applies.
1131
+ */
1132
+ function planeEndpointForBaseUrl(deps, baseUrl) {
1133
+ const resolution = deps.resolvePlaneEndpoint();
1134
+ if (!resolution.ok)
1135
+ return null;
1136
+ return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? resolution.endpoint : null;
1137
+ }
1138
+ /** What to tell the operator about a plane THIS invocation started and left up. */
1139
+ const LAUNCHED_PLANE_LEFT_RUNNING = [
1140
+ "",
1141
+ "The conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.",
1142
+ "No run was created and no executor lane was spawned. Inspect it with `plane status`,",
1143
+ "and wind it down with `plane down` once the item above is fixed.",
1144
+ ];
1145
+ /**
1146
+ * The bring-up facts and the plane-membership check, before any create.
1147
+ *
1148
+ * Returns the refusal lines, or `null` when admitted. Two questions:
1149
+ *
1150
+ * 1. The deployment-wide facts (`bring_up`), under their own refusal names —
1151
+ * a behind database, no worker, or two reconcilers refuse here exactly as
1152
+ * `setup-epic` would, and they must refuse here too because an attach to a
1153
+ * run that already exists never reaches setup-epic's create-path gate.
1154
+ * 2. Is THE PLANE'S OWN reconciler the fresh one? The deployment count cannot
1155
+ * say whose heartbeat it is; this instance-scoped read can. Combined with a
1156
+ * fresh count of exactly one, it proves the single live transition
1157
+ * authority is the reconciler of the plane being used. The identity is
1158
+ * rebuilt from the manifest, sent as a query parameter, and never rendered.
1159
+ */
1160
+ async function checkPlaneAdmission(ctx, readiness, manifest) {
1161
+ const bringUpRefusal = firstBringUpRefusal(readiness.bring_up ?? null);
1162
+ if (bringUpRefusal !== null) {
1163
+ return [
1164
+ `${ctx.epicKey}: REFUSED (${bringUpRefusal}) — ${BRING_UP_REFUSAL_MESSAGES[bringUpRefusal]}`,
1165
+ ];
1166
+ }
1167
+ let state;
1168
+ try {
1169
+ state = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
1170
+ }
1171
+ catch {
1172
+ state = "unavailable";
1173
+ }
1174
+ if (state === "fresh")
1175
+ return null;
1176
+ return [
1177
+ `${ctx.epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane's own reconciler could ` +
1178
+ `not be confirmed as the live transition authority (its heartbeat reads: ${state}).`,
1179
+ "Another reconciler may be standing in for this plane — often a reparented worker.py from an",
1180
+ `earlier plane. Check \`ps aux | grep worker.py\` (${BRING_UP_RUNBOOK} §6), stop any`,
1181
+ "survivor, and run drive-epic again.",
1182
+ ];
1183
+ }
1184
+ /**
1185
+ * COLD START: nothing is listening on the plane endpoint, so bring the control
1186
+ * plane up and compose the run against it.
1187
+ *
1188
+ * Reuses the BAPI-1102 two-phase launch unchanged — `executors: 1`, whose phase
1189
+ * one spawns the server and reconciler and no claiming lane — so there is one
1190
+ * bring-up ordering, not two.
1191
+ */
1192
+ async function coldStartPlane(ctx) {
1193
+ const { deps, epicKey } = ctx;
1194
+ // The cold start leaves the readiness `catch` before the ordinary advisory
1195
+ // read is reached, so it surfaces the BAPI-1022 containment advisory itself —
1196
+ // before anything is started, exactly as every other plan-file route does.
1197
+ if (ctx.options.planFile)
1198
+ await surfaceContainmentAdvisory(deps, ctx.options.planFile);
1199
+ deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the conductor plane.`);
1200
+ const repoRoot = deps.cwd();
1201
+ const preflight = await deps.runPlanePreflight(repoRoot);
1202
+ const precondition = firstPreconditionRefusal(preflight);
1203
+ if (precondition) {
1204
+ return refuse(deps, [
1205
+ `${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
1206
+ formatPlaneDiagnostic(precondition),
1207
+ "",
1208
+ "Nothing was started. Fix the item above and run drive-epic again.",
1209
+ ]);
1210
+ }
1211
+ const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
1212
+ if (!controlPlane.ok) {
1213
+ for (const line of controlPlane.lines)
1214
+ deps.errorLog(line);
1215
+ return 1;
1216
+ }
1217
+ void controlPlane.lifetime.catch(() => undefined);
1218
+ // Read ONCE, for route selection only — the pre-launch read had no server to
1219
+ // answer it. Not a runtime gate: runtime facts are not consulted here beyond
1220
+ // the classification, which treats them as repairable.
1221
+ let readiness;
1222
+ try {
1223
+ readiness = await deps.readReadiness(ctx.access);
1224
+ }
1225
+ catch (err) {
1226
+ return refuse(deps, [
1227
+ `${epicKey}: the plane came up, but its conductor readiness could not be read: ` +
1228
+ safeDiagnosticMessage(err, "readiness request failed"),
1229
+ ...LAUNCHED_PLANE_LEFT_RUNNING,
1230
+ ]);
1231
+ }
1232
+ const classification = classifyV2Readiness(readiness, {
1233
+ attended: ctx.options.attended === true,
1234
+ intoBase: ctx.options.intoBase === true,
1235
+ hasPolicyFile: ctx.options.policyFile !== undefined,
1236
+ });
1237
+ if (classification.kind === "operator_blocked") {
1238
+ return refuse(deps, [
1239
+ `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
1240
+ classification.remedy ??
1241
+ "Resolve it with `install-bridge conductor`, then run drive-epic again.",
1242
+ ...LAUNCHED_PLANE_LEFT_RUNNING,
1243
+ ]);
1244
+ }
1245
+ const manifestRead = await deps.readPlaneManifest(repoRoot);
1246
+ if (manifestRead.kind !== "valid" || manifestRead.manifest.planeId !== controlPlane.planeId) {
1247
+ return refuse(deps, [
1248
+ `${epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane manifest this invocation ` +
1249
+ "just wrote could not be read back, so its reconciler cannot be identified.",
1250
+ ...LAUNCHED_PLANE_LEFT_RUNNING,
1251
+ ]);
1252
+ }
1253
+ const admission = await checkPlaneAdmission(ctx, readiness, manifestRead.manifest);
1254
+ if (admission !== null) {
1255
+ return refuse(deps, [...admission, ...LAUNCHED_PLANE_LEFT_RUNNING]);
1256
+ }
1257
+ return createRunAndRequestLanes(ctx, {
1258
+ origin: "launched",
1259
+ repoRoot,
1260
+ planeId: controlPlane.planeId,
1261
+ awaitPlaneReady: controlPlane.awaitPlaneReady,
1262
+ });
1263
+ }
1264
+ /**
1265
+ * ATTACH: the plane endpoint already serves this repository's live plane.
1266
+ *
1267
+ * Starts no server and no reconciler — `launchControlPlane` is never called on
1268
+ * this path. Credentials resolve against the MANIFEST's repository root through
1269
+ * the shared resolver, as `plane down` does for a bound run.
1270
+ */
1271
+ async function attachToPlane(ctx, manifest, readiness) {
1272
+ const { deps, epicKey } = ctx;
1273
+ const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot);
1274
+ if (!rooted.ok) {
1275
+ return refuse(deps, [
1276
+ `${epicKey}: cannot attach to the running plane — Bridge API credentials could not be ` +
1277
+ `resolved for its repository: ${rooted.error}`,
1278
+ "",
1279
+ "Nothing was started. Fix the credential and run drive-epic again.",
1280
+ ]);
1281
+ }
1282
+ const attachCtx = { ...ctx, access: rooted.access };
1283
+ // R-7, decided BEFORE any create request.
1284
+ if (manifest.epicRunId !== undefined) {
1285
+ let bound = null;
1286
+ try {
1287
+ bound = await deps.readRunState(rooted.access, manifest.epicRunId);
1288
+ }
1289
+ catch {
1290
+ bound = null;
1291
+ }
1292
+ const why = bound === null
1293
+ ? "its bound run could not be read"
1294
+ : TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
1295
+ ? `its bound run is ${bound.status}`
1296
+ : bound.epicKey !== epicKey
1297
+ ? `its bound run belongs to ${bound.epicKey}`
1298
+ : null;
1299
+ if (why !== null) {
1300
+ return refuse(deps, [
1301
+ `${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
1302
+ `${manifest.epicRunId}, and ${why}.`,
1303
+ "A plane serves exactly one epic run (one epic per plane). Finish or stop that run, wind the",
1304
+ "plane down with `plane down`, and run drive-epic again.",
1305
+ "",
1306
+ "Nothing was started.",
1307
+ ]);
1308
+ }
1309
+ }
1310
+ const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
1311
+ if (admission !== null) {
1312
+ return refuse(deps, [...admission, "", "Nothing was started."]);
1313
+ }
1314
+ deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
1315
+ return createRunAndRequestLanes(attachCtx, {
1316
+ origin: "attached",
1317
+ repoRoot: manifest.repoRoot,
1318
+ planeId: manifest.planeId,
1319
+ ...(manifest.epicRunId !== undefined ? { boundRunId: manifest.epicRunId } : {}),
1320
+ });
1321
+ }
1322
+ /**
1323
+ * The shared tail: create/approve/cut through `setup-epic`, then request lanes
1324
+ * scoped to exactly that run.
1325
+ *
1326
+ * The `launched` wording is the BAPI-1102 wording, unchanged. The lane scope is
1327
+ * always `epic-runs` naming the one run setup returned — it renders through
1328
+ * `executorClaimScopeArgs` as `--epic-run-id`, and `--repo-wide` is not
1329
+ * constructible here.
1330
+ */
1331
+ async function createRunAndRequestLanes(ctx, plane) {
1332
+ const { deps, epicKey } = ctx;
1333
+ const launched = plane.origin === "launched";
1334
+ // ---- PHASE TWO, part one: create the run ------------------------------
1335
+ const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
1336
+ if (setup.exitCode !== 0) {
1337
+ // The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
1338
+ // was spawned — there is nothing to claim a job that may not exist.
1339
+ // Winding down automatically would stop the very run `setup-epic` may
1340
+ // have just created, because `plane down` stops the manifest-bound run
1341
+ // before terminating processes.
1342
+ deps.errorLog((launched
1343
+ ? [
1344
+ "",
1345
+ `${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
1346
+ "No executor lane was spawned, so nothing is claiming jobs.",
1347
+ "It was not wound down automatically, because doing so would stop a run that may",
1348
+ "already have been created and approved. Inspect it with `plane status`, and wind it",
1349
+ "down with `plane down` once you have decided what to do with the run.",
1350
+ ]
1351
+ : [
1352
+ "",
1353
+ `${epicKey}: the plane this invocation attached to was left running exactly as it was.`,
1354
+ "No executor lane was requested. Inspect it with `plane status`.",
1355
+ ]).join("\n"));
1356
+ return setup.exitCode;
1357
+ }
1358
+ if (setup.epicRunId === undefined) {
1359
+ // Exit zero with no run id is an ORCHESTRATION failure, not a success:
1360
+ // there is no authoritative identity to scope lanes to, and the one thing
1361
+ // this reordering exists to prevent is a lane started against a run
1362
+ // nobody named. Guessing — a repository-wide lookup, say — could scope
1363
+ // lanes to a DIFFERENT run on the same repository.
1364
+ deps.errorLog([
1365
+ "",
1366
+ `${epicKey}: setup reported success but returned no epic run id, so no executor lane`,
1367
+ "could be scoped to this run. NO LANE WAS SPAWNED. The control plane is still",
1368
+ "running: inspect it with `plane status` and wind it down with `plane down`.",
1369
+ ].join("\n"));
1370
+ return 1;
1371
+ }
1372
+ if (plane.boundRunId !== undefined && setup.epicRunId !== plane.boundRunId) {
1373
+ // The bound run was checked live and of this epic, so setup should have
1374
+ // reused it. A different id means the world moved between the check and the
1375
+ // create; requesting lanes now would point a single-run plane at a second run.
1376
+ deps.errorLog([
1377
+ "",
1378
+ `${epicKey}: setup resolved run ${setup.epicRunId}, but the attached plane is bound to run`,
1379
+ `${plane.boundRunId}. NO LANE WAS REQUESTED. Inspect both with \`plane status\` and the run's`,
1380
+ "own status before retrying.",
1381
+ ].join("\n"));
1382
+ return 1;
1383
+ }
1384
+ // ---- PHASE TWO, part two: scoped lanes, then the observer -------------
1385
+ const laneRequest = await deps.requestScopedLanes({
1386
+ repoRoot: plane.repoRoot,
1387
+ planeId: plane.planeId,
1388
+ epicRunId: setup.epicRunId,
1389
+ claimScope: { kind: "epic-runs", epicRunIds: [setup.epicRunId] },
1390
+ });
1391
+ if (!laneRequest.ok) {
1392
+ deps.errorLog([
1393
+ "",
1394
+ `${epicKey}: the run was created and approved, but executor lanes could not be`,
1395
+ `requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
1396
+ laneRequest.reason === "run-conflict"
1397
+ ? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
1398
+ : "Inspect it with `plane status`, and wind it down with `plane down`.",
1399
+ "The run itself is untouched and still committed server-side.",
1400
+ ].join("\n"));
1401
+ return 1;
1402
+ }
1403
+ if (plane.awaitPlaneReady === undefined) {
1404
+ // Attached: the plane's own detached runtime performs the spawn and the
1405
+ // per-lane readiness gating, and this process may exit first. Its progress
1406
+ // is the plane's to report.
1407
+ deps.log([
1408
+ "",
1409
+ `${epicKey}: attached — executor lane(s) scoped to run ${setup.epicRunId} were requested`,
1410
+ "from the running plane, whose runtime starts them and then the dead-man observer.",
1411
+ "`plane status` reports their progress; the run's own status reports dispatch.",
1412
+ ].join("\n"));
1413
+ return 0;
1414
+ }
1415
+ const planeReady = await plane.awaitPlaneReady();
1416
+ if (!planeReady.ok) {
1417
+ // No rollback of the RUN, deliberately. A lane that failed to start says
1418
+ // nothing about whether the run is valid, and stopping a committed,
1419
+ // approved run because a local process died would destroy state the
1420
+ // operator may well want to retry into.
1421
+ for (const line of planeReady.lines)
1422
+ deps.errorLog(line);
1423
+ deps.errorLog([
1424
+ "",
1425
+ `${epicKey}: no executor lane became ready, and the dead-man observer was NOT started.`,
1426
+ "The control plane and the committed run are both intact and were not rolled back:",
1427
+ "the runtime stops the lane cohort it started and keeps supervising the server and",
1428
+ "reconciler, so nothing is claiming jobs and the run keeps its server-side state.",
1429
+ "Read the per-lane logs under .bridge/plane/, then wind the plane down with",
1430
+ "`plane down` and re-run this command once the cause is fixed. A plane cannot be",
1431
+ "asked for lanes a second time: the runtime waits for that request exactly once.",
1432
+ ].join("\n"));
1433
+ return 1;
1434
+ }
1435
+ // Plane readiness is reported SEPARATELY from run dispatchability, and the
1436
+ // separation is the point: every process is up, but the reconciler still
1437
+ // holds dispatch while a feature branch's index scope finishes preparing.
1438
+ // An operator told "ready" who then sees no work start would go looking for
1439
+ // a broken plane that is working perfectly.
1440
+ deps.log([
1441
+ "",
1442
+ `${epicKey}: the plane is READY — control plane, scoped executor lane(s) for run ` +
1443
+ `${setup.epicRunId}, and the dead-man observer are all up.`,
1444
+ "That reports PROCESSES. If this run cuts an epic branch, the reconciler holds ticket",
1445
+ "dispatch until its index scope finishes preparing; `plane status` and the run's own",
1446
+ "status report that separately.",
1447
+ ].join("\n"));
1448
+ return 0;
1449
+ }