@bridge_gpt/mcp-server 0.2.69 → 0.2.70

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 (151) hide show
  1. package/README.md +140 -292
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +23 -4
  7. package/build/claude-review-workflow-drift.js +3 -3
  8. package/build/codex-bridge-profile.js +1 -1
  9. package/build/commands.generated.js +6 -6
  10. package/build/conductor/bridge-api-client.js +598 -19
  11. package/build/conductor/bring-up-facts.js +9 -9
  12. package/build/conductor/doctor.js +466 -18
  13. package/build/conductor/epic-child-continuation.js +54 -3
  14. package/build/conductor/epic-implementer-cli.js +132 -34
  15. package/build/conductor/epic-runtime.js +2 -2
  16. package/build/conductor/epic-state.js +1 -1
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/install-doctor.js +4 -2
  19. package/build/conductor/launch-need-probes.js +337 -0
  20. package/build/conductor/local-merge.js +241 -56
  21. package/build/conductor/merge-credential.js +172 -0
  22. package/build/conductor/merge-execution-eligibility.js +1 -1
  23. package/build/conductor/merged-child-check.js +95 -0
  24. package/build/conductor/plan-trim.js +30 -0
  25. package/build/conductor/pr-ci-producer.js +1 -1
  26. package/build/conductor/readiness-cli.js +11 -6
  27. package/build/conductor/readiness-prerequisites.js +41 -0
  28. package/build/conductor/readiness-sections.js +345 -55
  29. package/build/conductor/readiness.js +99 -87
  30. package/build/conductor/recovery-cli.js +606 -21
  31. package/build/conductor/recovery-operations.js +306 -7
  32. package/build/conductor/repair-contract.js +47 -2
  33. package/build/conductor/repository-workflow-remedy.js +8 -6
  34. package/build/conductor/stored-merge-credential.js +76 -0
  35. package/build/conductor/stored-merge-token-probe.js +28 -0
  36. package/build/conductor/temporary-plan.js +50 -0
  37. package/build/conductor/wait-run-cli.js +759 -0
  38. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  39. package/build/conductor-bin.js +2 -2
  40. package/build/conductor-claude-hook-bin.js +2 -2
  41. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  42. package/build/conductor-removed-stub-bin.js +2 -5
  43. package/build/credential-store.js +356 -13
  44. package/build/docs.generated.js +26 -13
  45. package/build/doctor.js +94 -32
  46. package/build/drive-epic-help.js +212 -0
  47. package/build/drive-epic.js +1840 -373
  48. package/build/epic-ci-workflow-drift.js +77 -0
  49. package/build/epic-ci-workflow-install.js +183 -0
  50. package/build/epic-ci-workflow.js +157 -0
  51. package/build/epic-contract-context.js +326 -0
  52. package/build/epic-implementer-bin.js +57 -41
  53. package/build/epic-implementer-claude-hook-bin.js +2 -2
  54. package/build/escalation-reachability.js +134 -0
  55. package/build/executor/cleanup-report.js +99 -0
  56. package/build/executor/cli.js +12 -1
  57. package/build/executor/completion-contract.js +66 -11
  58. package/build/executor/completion-supervision.js +99 -0
  59. package/build/executor/delivery-outcome.js +179 -0
  60. package/build/executor/deps.js +3 -0
  61. package/build/executor/env.js +71 -0
  62. package/build/executor/failure-reason-codes.js +126 -0
  63. package/build/executor/heartbeat.js +38 -0
  64. package/build/executor/http-client.js +72 -0
  65. package/build/executor/integration-job.js +298 -5
  66. package/build/executor/job-errors.js +36 -6
  67. package/build/executor/job-runner.js +720 -36
  68. package/build/executor/likely-tests.js +36 -2
  69. package/build/executor/merge-job.js +145 -2
  70. package/build/executor/observation.js +73 -4
  71. package/build/executor/permissions.js +193 -97
  72. package/build/executor/preflight.js +14 -3
  73. package/build/executor/process-containment.js +28 -2
  74. package/build/executor/prompt-spec.js +50 -5
  75. package/build/executor/rebase-finalization.js +38 -3
  76. package/build/executor/reconcile-overlap-job.js +35 -7
  77. package/build/executor/required-work-ledger.js +236 -81
  78. package/build/executor/resume-pre-spawn.js +38 -1
  79. package/build/executor/runner.js +91 -3
  80. package/build/executor/test-clock.js +8 -0
  81. package/build/executor/types.js +37 -2
  82. package/build/executor/worker-cli-launcher.js +259 -0
  83. package/build/executor/worker-command.js +41 -4
  84. package/build/executor/worker-database-target.js +91 -0
  85. package/build/executor/worker-database.js +908 -0
  86. package/build/executor/worker-finalization.js +106 -0
  87. package/build/executor/worker-guard-hook.js +203 -0
  88. package/build/executor/worker-resources.js +47 -0
  89. package/build/executor/worker-verification-guidance.js +15 -2
  90. package/build/executor/worktree.js +303 -15
  91. package/build/executor-launch.js +11 -1
  92. package/build/generated-assets-doctor.js +9 -1
  93. package/build/generated-assets-health.js +13 -4
  94. package/build/generated-assets-legacy.js +39 -0
  95. package/build/generated-assets-paths.js +12 -1
  96. package/build/generated-assets-repair.js +4 -1
  97. package/build/generated-assets-test-support.js +12 -0
  98. package/build/github-remote.js +33 -0
  99. package/build/github-sign-in.js +62 -0
  100. package/build/index.js +1067 -536
  101. package/build/init.js +39 -1
  102. package/build/install-bridge-conductor-gate.js +78 -0
  103. package/build/install-bridge-conductor-setup.js +413 -0
  104. package/build/install-bridge-conductor.js +1226 -267
  105. package/build/install-bridge.js +376 -60
  106. package/build/mcp-install-state.js +8 -2
  107. package/build/merge-pull-request.js +6 -0
  108. package/build/merge-workflow-install.js +763 -345
  109. package/build/phase-result-artifacts.js +75 -20
  110. package/build/pipeline-orchestrator.js +247 -4
  111. package/build/pipelines.generated.js +35 -35
  112. package/build/plan-discovery.js +15 -2
  113. package/build/plan-execution-ledger.js +306 -17
  114. package/build/plan-phase-routing.js +48 -13
  115. package/build/plane/cli.js +105 -51
  116. package/build/plane/defaults.js +3 -0
  117. package/build/plane/plane-freshness.js +294 -0
  118. package/build/plane/schema-compatibility.js +137 -0
  119. package/build/plane/shutdown-deps.js +38 -0
  120. package/build/plane/shutdown.js +83 -8
  121. package/build/plane/terminal-run-cleanup.js +303 -0
  122. package/build/printed-remedy-commands.js +26 -4
  123. package/build/readiness-check.js +6 -2
  124. package/build/readiness-prerequisites.generated.js +532 -0
  125. package/build/readme.generated.js +1 -1
  126. package/build/setup-epic.js +400 -30
  127. package/build/shared-resources.generated.js +5 -0
  128. package/build/start-tickets.js +28 -15
  129. package/build/test-command-detection.js +247 -0
  130. package/build/test-command-validation.js +73 -0
  131. package/build/testing-config-client.js +150 -0
  132. package/build/ticket-delivery-artifacts.js +258 -0
  133. package/build/ticket-delivery-enrollment.js +306 -0
  134. package/build/ticket-delivery.js +205 -0
  135. package/build/ticket-review-summary.js +14 -0
  136. package/build/upgrade-cli.js +13 -0
  137. package/build/v2-readiness-requirements.js +552 -0
  138. package/build/version.generated.js +3 -3
  139. package/build/worker-guard-hook-bin.js +2 -2
  140. package/build/worktree-core.js +22 -7
  141. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  142. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  143. package/docs/EPIC-IMPLEMENTER.md +104 -63
  144. package/docs/epic-implementer/getting-started.md +131 -0
  145. package/docs/epic-implementer/self-hosting.md +133 -0
  146. package/package.json +3 -3
  147. package/pipelines/idea-to-ticket.json +1 -1
  148. package/pipelines/implement-ticket.json +8 -8
  149. package/pipelines/review-ticket.json +2 -2
  150. package/design-assets/favicon/favicon-32x32.png +0 -0
  151. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -1,3 +1,6 @@
1
+ import { ticketReviewSummary } from "./ticket-review-summary.js";
2
+ import { parseConductorReadinessResponse } from "./conductor/bridge-api-client.js";
3
+ import { READINESS_PRE_CREATE_CODES, READINESS_PREREQUISITE_BY_ID, READINESS_LEGACY_IDS } from "./readiness-prerequisites.generated.js";
1
4
  /**
2
5
  * `drive-epic` — the one universal conductor entry point (BAPI-887).
3
6
  *
@@ -53,32 +56,49 @@
53
56
  * ratcheted down, and a tool would be useless in a bare-chat session anyway,
54
57
  * where the model has no tool call to make.
55
58
  */
56
- import { readdir, readFile } from "node:fs/promises";
59
+ import { access as fsAccess, readdir, readFile, stat as fsStat } from "node:fs/promises";
60
+ import { constants as fsConstants } from "node:fs";
57
61
  import path, { dirname, join } from "node:path";
58
- import { CONDUCTOR_DEFAULT_BASE_URL, ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, gateProfileWaivesCiWorkflow, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
62
+ import { ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchRepositoryUsageAdvisory, REPOSITORY_USAGE_ADVISORY_UNAVAILABLE, formatSchemaIncompatibleRefusal, isSchemaIncompatibleError, SCHEMA_INCOMPATIBLE_REPAIR_ROUTE, fetchEpicRunState, isProviderRepositoryIdentity, safeDiagnosticMessage, classifyChildMergeStatus as classifyChildMergeStatusClient, recordSkippedChildren as recordSkippedChildrenClient, } from "./conductor/bridge-api-client.js";
63
+ import { CHILD_MERGE_STATUS_BATCH_SIZE, buildAlreadyDoneEntries, classifyPlanChildren, selectSkips, } from "./conductor/merged-child-check.js";
64
+ import { trimDonePlanNodes } from "./conductor/plan-trim.js";
65
+ import { withPlanPath } from "./conductor/temporary-plan.js";
66
+ import { ESCALATION_REACHABILITY_CHECK_ID, buildEscalationReachabilityCheck, } from "./escalation-reachability.js";
59
67
  import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, epicLaunchPolicyDigest, pinEpicLaunchPolicy, renderEpicLaunchCommand, resolveEpicLaunchSelection, shellQuote, } from "./epic-launch-profile.js";
60
68
  import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
61
69
  import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
62
- import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
63
- import { policyDeclaresNotifyWebhook, policyNotifyDestinationsConflict, notifyDestinationsConflictMessage, effectivePolicyFacts, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
70
+ import { V2_READINESS_REQUIREMENTS, buildLaunchReadinessChecks, firstLaunchEvidenceBlocker, formatLaunchReadinessSummary, projectReadinessRowBlocking, unattendedPostureCheck, } from "./v2-readiness-requirements.js";
71
+ import { classifyLocalGhPreflight, probeLocalMergeCapability, } from "./conductor/local-merge.js";
72
+ import { nodeGithubMergeCredentialReadDeps, resolveMergeCredentialAtAction, } from "./conductor/stored-merge-credential.js";
73
+ import { resolveWorktreeSettingsExclude, } from "./executor/permissions.js";
74
+ import { collectWorkerDatabaseDoctorSection, WORKER_DATABASE_DOCTOR_TIMEOUT_MS, } from "./conductor/doctor.js";
75
+ import { buildEpicBranchBehindBaseLaunchRow, buildHostPowerLaunchRow, buildSelfHostedRunnerLaunchRow, buildUsageResetAfterRunLaunchRow, buildWorkerDbRoleLaunchRow, hostPowerFactFromReport, workerDatabaseReadinessChecks, } from "./conductor/readiness-sections.js";
76
+ import { createWorkflowSource, inspectEpicBranchBehindBase, inspectRequiredSelfHostedRunners, } from "./conductor/launch-need-probes.js";
77
+ import { createWorkerDatabaseDoctorInspector } from "./conductor/worker-database-doctor-inspector.js";
78
+ import { policyDeclaresNotifyWebhook, policyNotifyDestinationsConflict, notifyDestinationsConflictMessage, effectivePolicyFacts, readSetupEpicPolicyFile, readCanonicalParseTruth as readSetupCanonicalParseTruth, runSetupEpicWorkflow, } from "./setup-epic.js";
64
79
  import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
65
80
  import { flushProcessStdout, writeJsonDocument } from "./conductor/json-output.js";
81
+ import { BRIDGE_LAUNCHER_ENV } from "./bridge-permissions.js";
82
+ import { checkPlaneBuildFreshness, PLANE_BUILD_FIX_REMEDIATION } from "./plane/build-freshness.js";
66
83
  import { discoverEpicPlan, } from "./plan-discovery.js";
67
84
  import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
68
85
  import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
69
- import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
86
+ import { createPlaneExecFile, createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
70
87
  import { readPlaneManifest } from "./plane/manifest.js";
88
+ import { cleanupTerminalRunPlane, createTerminalRunCleanupDeps, terminalRunCleanupStderrLine, } from "./plane/terminal-run-cleanup.js";
89
+ import { checkPlaneFreshness, inspectHostSleep, PLANE_DATABASE_HEAD_ID, PLANE_DATABASE_HEAD_REMEDY, PLANE_FRESHNESS_UNVERIFIED_REMEDY, PLANE_HOST_SLEEP_ID, PLANE_SLEEP_REMEDY, formatBoundPlaneStaleRemedy, PLANE_SNAPSHOT_FRESHNESS_ID, PLANE_SNAPSHOT_STALE_REMEDY, } from "./plane/plane-freshness.js";
71
90
  import { baseUrlNamesPlaneEndpoint, planeEndpointForTarget } from "./plane/endpoint.js";
72
91
  import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
73
92
  import { resolveMemberInstanceId } from "./plane/supervisor.js";
74
- import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
75
93
  import { validateBranchName } from "./base-ref.js";
76
94
  import { createDefaultRunExecutorLaunchDeps, ensureRunExecutor as ensureRunExecutorDefault, prepareRunExecutor as prepareRunExecutorDefault, } from "./executor-launch.js";
77
95
  import { isTerminalEpicRunStatus } from "./epic-run-status.js";
78
96
  // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
79
97
  // the printed invocation interpolates it rather than repeating it.
80
98
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
81
- import { PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_DOCTOR_COMMAND, } from "./printed-remedy-commands.js";
99
+ import { getDriveEpicAdvancedUsage, getDriveEpicUsage } from "./drive-epic-help.js";
100
+ export { getDriveEpicUsage };
101
+ import { CONSUMER_GUIDE_POINTER, PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_DOCTOR_COMMAND, } from "./printed-remedy-commands.js";
82
102
  /** The conductor paths. Derived types below come from this constant. */
83
103
  export const DRIVE_EPIC_CONDUCTORS = ["v2"];
84
104
  /**
@@ -89,135 +109,12 @@ export const DRIVE_EPIC_CONDUCTORS = ["v2"];
89
109
  * one named constant rather than branching scattered through the routing logic.
90
110
  */
91
111
  export const DRIVE_EPIC_PREFERRED_CONDUCTOR = "v2";
92
- /**
93
- * The v2 readiness predicate, as a list so a failure can name what is missing.
94
- *
95
- * These are the facts v2 actually needs to drive an epic: a stored supervisor
96
- * posture, a reconciler that is ticking, a provisioned and live executor, and
97
- * complete GitHub App credentials. Each is read from the SERVER's report and
98
- * never re-derived locally — two implementations of one classification drift,
99
- * and a CLI that disagreed with the server about whether v2 can run would be
100
- * worse than no check at all.
101
- */
102
- export const V2_READINESS_REQUIREMENTS = [
103
- {
104
- id: "supervisor_setup",
105
- describe: "supervisor setup stored for this repository",
106
- satisfied: (r) => r.supervisor.setup_present,
107
- },
108
- {
109
- id: "supervisor_config",
110
- describe: "supervisor configuration stored for this repository",
111
- satisfied: (r) => r.supervisor.config_present,
112
- },
113
- {
114
- id: "github_credentials",
115
- describe: "GitHub App credentials that resolve completely",
116
- satisfied: (r) => r.github.credentials_complete,
117
- },
118
- {
119
- id: "reconciler_live",
120
- describe: "a reconciler that is ticking and not stale",
121
- // BAPI-1279 — launch liveness is a DEPLOYMENT fact. The `bring_up` block
122
- // reads the reconciler's process heartbeat deployment-wide, so a repository
123
- // that has never run an epic still reads live when the worker is healthy.
124
- // The repository-scoped `reconciler` section ("is this repository's work
125
- // moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
126
- // too old to report `bring_up`.
127
- satisfied: (r) => r.bring_up
128
- ? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
129
- r.bring_up.single_transition_authority
130
- : r.reconciler.liveness_readable && !r.reconciler.stale,
131
- },
132
- {
133
- id: "executor_live",
134
- describe: "an executor provisioned and reporting ready",
135
- // BAPI-1279 — diagnostic only. drive-epic starts (or, for a durable profile,
136
- // verifies) the run's executor itself once the run exists.
137
- advisoryOnly: true,
138
- satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
139
- },
140
- // BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
141
- // refuses them, so readiness and the refusal name the same first blocker.
142
- //
143
- // Each reads the SERVER's `unattended` block and re-derives nothing. A server
144
- // that does not report the block (older than BAPI-1102) leaves every one of
145
- // them UNSATISFIED: "could not be read" is not "holds", and these authorize an
146
- // unattended merge.
147
- //
148
- // The FIRST of them therefore carries the older-server remedy too, because it
149
- // is the one an unreported block always blocks on, and telling that operator
150
- // to set a consent flag would send them to fix something that may already be
151
- // set — the server simply never said (BAPI-1102 review).
152
- {
153
- id: "unattended_consent",
154
- describe: "repository consent to unattended Epic Implementer runs",
155
- unattendedOnly: true,
156
- // No fixed `remedy`: this requirement's action always depends on WHY.
157
- remedyFor: (r) => r.unattended === null || r.unattended === undefined
158
- ? "This Bridge API deploy does not report a repository's unattended " +
159
- "prerequisites at all, so none of them could be confirmed. Upgrade the " +
160
- "deploy, or pass --attended."
161
- : "Unattended runs are off for this repository. Turn on 'Allow unattended " +
162
- "Epic Implementer runs' on the repository's Setup page in the Bridge " +
163
- "admin panel, or pass --attended. If it is already on, contact Bridge support.",
164
- satisfied: (r) => r.unattended?.conductor_allowed === true,
165
- },
166
- {
167
- id: "unattended_notify_default",
168
- describe: "an escalation channel (a verified notify webhook or the account email)",
169
- unattendedOnly: true,
170
- remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
171
- "to the account so escalation alerts can be emailed, or pass --attended. " +
172
- "notify.local_sink does not satisfy this.",
173
- // BAPI-1274 — the account's email address is an escalation channel when no
174
- // webhook is declared; the server reports its presence, never the address.
175
- satisfied: (r) => r.unattended?.notify_webhook_default_declared === true ||
176
- r.unattended?.escalation_email_available === true,
177
- },
178
- {
179
- id: "repository_readiness",
180
- describe: "confirmed review and epic-implementer-ci workflows on the default branch",
181
- unattendedOnly: true,
182
- // BAPI-1359 (Miss 38): the remedy names the SPECIFIC missing item, from the
183
- // same shared formatter the readiness row and the setup-epic refusal use. The
184
- // fixed install-command sentence that stood here could never clear a missing
185
- // migration-guard job — no install step creates one — so it is gone from this
186
- // refusal.
187
- //
188
- // BAPI-1390: the server decides which files are required (the review file
189
- // only for a `verdict_protocol` run; the CI lane file only for a repository
190
- // with no STORED gate profile and no `no_ci_gate`), and `satisfied` reads that
191
- // decision as given. The remedy waives the CI file the same way, so a stored
192
- // profile is never told to add a file it does not need. Gate-profile WARNINGS
193
- // (epic-branch rows) are not requirements and are never read here.
194
- remedyFor: (r) => repositoryWorkflowRemedy(gateProfileWaivesCiWorkflow(r) ? { ...r, conductor_ci_workflow: null } : r),
195
- satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
196
- },
197
- // BAPI-1135 — merge-execution eligibility is deliberately NOT a requirement here.
198
- //
199
- // It was one, and it made `drive-epic` refuse EVERY repository the moment it
200
- // shipped. The requirement could only be keyed on repository facts, because
201
- // this layer keeps the composed run policy opaque to itself — and no
202
- // repository carries the merge workflow until epic child 4 lands it. So both
203
- // states resolved the same way: an upgraded server reported
204
- // `merge_workflow_present: false` (`not_eligible`), an older one reported no
205
- // block at all (`could_not_verify`), and either made `selectConductor` return
206
- // `not_ready` and `classifyV2Readiness` return `operator_blocked` — for
207
- // `--attended`, `--into-base` and `local`-mode runs alike, with a remedy
208
- // naming a flag that does not exist yet.
209
- //
210
- // That is exactly the failure `selectConductor`'s own docstring warns about,
211
- // and it contradicts the compatibility guarantee the rest of this slice keeps:
212
- // nothing changes for a run that does not declare `github_actions`.
213
- //
214
- // Eligibility is still enforced, at the three layers that can actually see the
215
- // declared mode: `setup-epic`'s pre-create refusal (which reads the composed
216
- // policy), the approval route's server-side gate, and — authoritatively — the
217
- // before-merge preflight. Readiness still REPORTS the facts as diagnostic
218
- // rows, which inform without blocking. Reinstating a requirement here belongs
219
- // with child 4, once a repository can actually satisfy it.
220
- ];
112
+ // BAPI-1452: `V2_READINESS_REQUIREMENTS` moved to `./v2-readiness-requirements.ts`
113
+ // with an explicit governed row-ID list on every requirement, so readiness rows
114
+ // derive `blocking` from requirement applicability alone. It is re-exported
115
+ // here for existing consumers; classification and conductor selection stay in
116
+ // this module.
117
+ export { V2_READINESS_REQUIREMENTS };
221
118
  /**
222
119
  * The readiness facts a `plane up` can actually repair (BAPI-1054).
223
120
  *
@@ -237,7 +134,9 @@ export const V2_READINESS_REQUIREMENTS = [
237
134
  * running plane behind and change nothing — which is the precise failure the
238
135
  * classification exists to prevent.
239
136
  */
240
- export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
137
+ export const V2_RUNTIME_READINESS_IDS = ["server.worker-present"];
138
+ /** BAPI-1452 — the credential capability row id, for naming its refusal. */
139
+ const MERGE_CREDENTIAL_ROW = "conductor.merge-credential-capability";
241
140
  /**
242
141
  * Classify the SERVER's readiness report into the composition's three cases.
243
142
  *
@@ -250,13 +149,22 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
250
149
  * three competing actions has to work out which to do first, and the order here
251
150
  * already encodes that (setup, then config, then credentials).
252
151
  */
253
- export function classifyV2Readiness(readiness, options = {}) {
152
+ export function classifyV2Readiness(readiness, options = {},
153
+ /**
154
+ * BAPI-1452 — the value-free launch evidence (credential probe verdict and
155
+ * worktree-preparation result) collected locally before run creation. A
156
+ * separate input rather than a new option, so the server report and the
157
+ * local evidence stay visibly distinct. Absent: no evidence was collected,
158
+ * and the classification is exactly the pre-BAPI-1452 one. The unattended
159
+ * posture is never read from here — only from the server report.
160
+ */
161
+ launchEvidence) {
254
162
  for (const diagnostic of readiness.diagnostics ?? []) {
255
163
  if (diagnostic.severity === null || diagnostic.applicability === null) {
256
164
  return {
257
165
  kind: "operator_blocked",
258
166
  id: "diagnostic-metadata-unknown",
259
- describe: "declared diagnostic severity and applicability",
167
+ describe: "Additional readiness diagnostics",
260
168
  remedy: "Upgrade or repair the readiness producer, then retry the selected target.",
261
169
  };
262
170
  }
@@ -264,7 +172,7 @@ export function classifyV2Readiness(readiness, options = {}) {
264
172
  return {
265
173
  kind: "operator_blocked",
266
174
  id: "mandatory-diagnostic-unverified",
267
- describe: `mandatory diagnostic ${diagnostic.id}`,
175
+ describe: "Additional readiness diagnostics",
268
176
  remedy: "Resolve the mandatory diagnostic at the selected Bridge API target and retry.",
269
177
  };
270
178
  }
@@ -287,7 +195,7 @@ export function classifyV2Readiness(readiness, options = {}) {
287
195
  const deferred = options.approvedStoredPolicy ||
288
196
  options.effectivePolicy?.escalationChannel === "configured webhook" ||
289
197
  (options.effectivePolicy === undefined && options.hasPolicyFile)
290
- ? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
198
+ ? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "server.unattended-notify-default")
291
199
  : V2_READINESS_REQUIREMENTS;
292
200
  // `--into-base` is the second opt-out, and it is structural rather than
293
201
  // declared: it selects the branch-silent composition, whose policy carries no
@@ -303,6 +211,30 @@ export function classifyV2Readiness(readiness, options = {}) {
303
211
  ? gating.filter((req) => req.unattendedOnly !== true)
304
212
  : gating;
305
213
  const unmet = applicable.filter((req) => !req.satisfied(readiness));
214
+ // BAPI-1452 — launch evidence. Only a PROVEN gap (missing or invalid
215
+ // credential, proven no access, proven worktree impossibility) refuses;
216
+ // unverified evidence is a warning and never a pass. Server-fact blockers
217
+ // keep priority, so a disabled posture is still named first.
218
+ const launchChecks = launchEvidence !== undefined && !optedOut
219
+ ? buildLaunchReadinessChecks(readiness, {
220
+ attended: options.attended,
221
+ intoBase: options.intoBase,
222
+ launchEvidence,
223
+ })
224
+ : [];
225
+ const launchBlocker = firstLaunchEvidenceBlocker(launchChecks);
226
+ const serverBlocker = unmet.find((req) => !(V2_RUNTIME_READINESS_IDS.includes(req.id) || V2_RUNTIME_READINESS_IDS.some(id => READINESS_LEGACY_IDS[id] === req.id)));
227
+ if (launchBlocker !== null && serverBlocker === undefined) {
228
+ return {
229
+ kind: "operator_blocked",
230
+ id: launchBlocker.id.slice(launchBlocker.id.indexOf(".") + 1),
231
+ describe: launchBlocker.id === MERGE_CREDENTIAL_ROW
232
+ ? "a merge credential that has push and can read pull requests"
233
+ : "an executor clone whose shared git exclude can be prepared",
234
+ remedy: `${launchBlocker.detail ?? launchBlocker.label}. ${launchBlocker.remediation}`,
235
+ };
236
+ }
237
+ const launchWarnings = launchChecks.filter((check) => check.status === "warn");
306
238
  if (unmet.length === 0) {
307
239
  // Older deployments have no bring-up block; their existing reconciler
308
240
  // liveness predicate remains the available evidence. For deployments that
@@ -323,13 +255,16 @@ export function classifyV2Readiness(readiness, options = {}) {
323
255
  remedy: "Resolve the missing or duplicate reconciler process before launch.",
324
256
  };
325
257
  }
326
- return { kind: "green" };
258
+ return launchWarnings.length > 0 ? { kind: "green", warnings: launchWarnings } : { kind: "green" };
259
+ }
260
+ if (readiness.is_production && (serverBlocker?.id === 'server.worker-present' || unmet.some(req => req.id === 'server.worker-present'))) {
261
+ return { kind: 'operator_blocked', id: 'service-availability', describe: 'Service availability', remedy: 'Retry readiness before launching.' };
327
262
  }
328
- const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
263
+ const blocker = serverBlocker;
329
264
  if (blocker) {
330
265
  return {
331
266
  kind: "operator_blocked",
332
- id: blocker.id,
267
+ id: { "server.indexed-workflows": READINESS_PRE_CREATE_CODES.INDEXED_WORKFLOWS_NOT_MET, "server.unattended-notify-default": READINESS_PRE_CREATE_CODES.ESCALATION_CHANNEL_NOT_MET, "server.test-command": READINESS_PRE_CREATE_CODES.TEST_COMMAND_NOT_MET }[blocker.id] ?? blocker.id,
333
268
  describe: blocker.describe,
334
269
  ...(() => {
335
270
  const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
@@ -339,157 +274,14 @@ export function classifyV2Readiness(readiness, options = {}) {
339
274
  }
340
275
  return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
341
276
  }
342
- export function getDriveEpicUsage() {
343
- return [
344
- "Usage: mcp-server drive-epic [options] <EPIC>",
345
- "",
346
- "Drives one epic with the conductor this project can actually run. Reads",
347
- "conductor readiness from Bridge API and routes to exactly one path — it",
348
- "never asks you to choose.",
349
- "",
350
- "Arguments:",
351
- " <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
352
- "",
353
- "Branch strategy (v2): a plan with two or more nodes defaults to a dedicated",
354
- "epic/<KEY> branch — child PRs target it, one draft integration PR targets the",
355
- "repository base branch. Both flags below only override that default; the",
356
- "strategy itself is decided by the bootstrap this command delegates to, never",
357
- "here.",
358
- "",
359
- "Posture (v2, BAPI-1102): a run that selects an epic branch is created",
360
- "UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
361
- "the server stamps its CI gate at first approval. The INTEGRATION PR into the",
362
- "repository base branch stays human-gated. Pass --attended to opt out. An",
363
- "unattended run has three repository prerequisites (consent, which is ON by",
364
- "default and switched on the Bridge admin panel's Setup page; an escalation",
365
- "channel, either a verified notify webhook or the account's email address; and",
366
- "confirmed review and CI workflows on the default branch) that readiness lists",
367
- "with their state and that are refused BY NAME before any run is created.",
368
- "",
369
- "Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
370
- "created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
371
- "this checkout's Git top level. It claims only that run's jobs, logs to",
372
- ".bridge/executors/<run>.log, and exits on its own when the run is done or",
373
- "abandoned. Re-running for the same live run starts no second one. A durable",
374
- "launch profile starts none: it requires a fresh --repo-wide executor instead.",
375
- "Checks that need no run happen FIRST, so a refusal before the run is created",
376
- "creates nothing; an executor that fails to start after it leaves the run intact",
377
- "and prints the log path and the exact command to start it by hand.",
378
- "",
379
- "Reconciler liveness is read from the deployment's heartbeat. Against a target",
380
- "that is not this machine's plane (production, say), drive-epic never touches a",
381
- "local plane: a target with no live reconciler is refused by name.",
382
- "",
383
- "One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
384
- "starts the conductor runtime for you against the local plane endpoint, when",
385
- "readiness is missing ONLY the reconciler. It brings up the CONTROL PLANE first",
386
- "(server and reconciler), creates and approves the run, then starts executor lanes",
387
- "SCOPED to that run, and starts the dead-man observer last — one invocation, no",
388
- "manual `plane up` first. A report missing supervisor setup,",
389
- "supervisor configuration, GitHub App credentials, or an unattended prerequisite",
390
- "is NOT started: those are operator-owned, and drive-epic exits with that one",
391
- "named reason instead.",
392
- "",
393
- "The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
394
- `local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
395
- "drive-epic handles the port one of three ways, and never asks you to choose:",
396
- " - cold start: nothing listens there — it brings the plane up, then creates",
397
- " the run;",
398
- " - attach: this repository's own healthy plane is there — it creates the run",
399
- " against that plane and starts NO second server or reconciler;",
400
- " - refusal: anything else holds the port (another listener, a manifest for a",
401
- " different repository, an unreadable manifest, a plane that cannot take a",
402
- " run) — one named reason, and nothing is started.",
403
- "One epic per plane — for its LANES: a plane's lanes serve the one run it is",
404
- "bound to. A second run attaches to the same plane's reconciler and gets its own",
405
- "run-scoped executor instead. A plane whose bound run cannot be read is refused",
406
- "as plane-already-bound. Concurrent planes on one database are not supported.",
407
- "Executor lanes and run-scoped executors are always scoped to the one run with",
408
- "--epic-run-id, on every path.",
409
- "",
410
- "Finding the plan (BAPI-1285): without --plan-file, drive-epic searches",
411
- "{docs_dir}/epic-plans/*/epic-plan.meta.json for a file naming this epic",
412
- "({\"epic_key\": \"<KEY>\"}), where docs_dir is BAPI_DOCS_DIR or docs/tmp",
413
- "resolved against the Git top level. Exactly one match with a sibling",
414
- "epic-plan.dag.json is used as if you had passed --plan-file, and its path is",
415
- "printed before anything is created. It never guesses: no match, two or more",
416
- "matches, or an unusable meta file stops with the paths it found and creates",
417
- "nothing. It never matches by folder name, child ticket key, or manifest. A",
418
- "supplied --plan-file always wins, and discovery does not run. The meta file is",
419
- "written by `emit-epic-implementer-bundle finalize`; a plan finalized before",
420
- "that has none, so pass its DAG with --plan-file.",
421
- "",
422
- "A live run with no plan: when the epic already has a live run and no plan was",
423
- "supplied or found, drive-epic does not create anything. It makes sure that run",
424
- "has an executor (reusing a live one, never starting a second) and reports it. A",
425
- "durable launch profile starts none and relies on its host's confirmed repo-wide",
426
- "executor; a plane already bound to the run needs none. An epic with several active runs, or",
427
- "a run that cannot be read, is refused by name.",
428
- "",
429
- "Preconditions are validated, never repaired: the current directory must be a Git",
430
- "work tree inside the target repository, because the run's executor starts there.",
431
- "No worktree is created for you and no replacement port is chosen for you — each",
432
- "failure names the fix. Fix it and rerun.",
433
- "",
434
- "Options:",
435
- " --plan-file <path> Plan DAG sidecar. Optional: without it, drive-epic finds",
436
- " the plan by epic key (see above). A supplied path always",
437
- " wins, and when the v2 path is selected drive-epic runs",
438
- " the bootstrap directly with it.",
439
- " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
440
- " --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
441
- " verbatim, never interpreted here.",
442
- " --into-base OPT OUT of the epic-branch default: children target the",
443
- " repository base branch (the pre-BAPI-1009 behavior).",
444
- " Contradictory with --feature-branch; passing both is a",
445
- " parse error.",
446
- " --attended OPT OUT of the unattended default. Forwarded verbatim to",
447
- " the bootstrap, which owns what it composes; drive-epic",
448
- " reads it only to skip the unattended prerequisites.",
449
- "",
450
- "Policy (forwarded verbatim; drive-epic interprets none of it):",
451
- " --policy-file <path> JSON file holding the COMPLETE run policy.",
452
- " --review-policy <src> PER-RUN review policy source.",
453
- " --replace-policy Authorize replacing a LIVE run's stored policy.",
454
- "",
455
- "Launch target (BAPI-1260) — decided BEFORE any authenticated request:",
456
- " --launch-profile <name> Select the host-local launch profile guided setup",
457
- " approved (`install conductor --launch-profile <name>`):",
458
- " target, repository, mode, runtime, and policy and",
459
- " credential references. Useful for a local or other",
460
- " non-production server and for durable hosts.",
461
- " --base-url <url> Explicit Bridge API target. With a profile it must",
462
- " agree with it — an override never rebinds a profile.",
463
- "BAPI_EPIC_LAUNCH_PROFILE selects a host-local launch profile for epic",
464
- "launches. --launch-profile takes precedence. For an environment-selected",
465
- "profile, BAPI_BASE_URL remains the general CLI endpoint and does not",
466
- "conflict with the epic launch target; --base-url still must agree.",
467
- "Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
468
- `targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
469
- "stored bapi:<repo> key when one is available. A stored key approved for a",
470
- "different endpoint is never sent; an unapproved key is sent only to",
471
- "production.",
472
- "",
473
- "Preview (BAPI-1285):",
474
- " --dry-run Read-only launch preview. Resolves the target, reads",
475
- " readiness and the epic's live run, discovers the plan,",
476
- " and runs the bootstrap's own dry-run validation. It",
477
- " reports what a real run WOULD do (create, attach,",
478
- " refuse, or stop) and never creates a run, cuts a",
479
- " branch, starts a plane, requests a lane, or starts or",
480
- " signals an executor. Exit 0 for create or attach, 1 for",
481
- " refuse or stop.",
482
- " --json With --dry-run only: print exactly one JSON document on",
483
- " stdout and nothing else. Alone, it is a parse error.",
484
- "",
485
- " -h, --help Show this help",
486
- ].join("\n");
487
- }
488
277
  /**
489
278
  * Parse argv. Rejects malformed input BEFORE any side effect — no readiness
490
279
  * read, no credential resolution, no delegation.
491
280
  */
492
281
  export function parseDriveEpicArgs(argv) {
282
+ if (argv.includes("--help-advanced")) {
283
+ return { status: "help", usage: getDriveEpicAdvancedUsage() };
284
+ }
493
285
  if (argv.includes("-h") || argv.includes("--help")) {
494
286
  return { status: "help", usage: getDriveEpicUsage() };
495
287
  }
@@ -528,8 +320,46 @@ export function parseDriveEpicArgs(argv) {
528
320
  // BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
529
321
  let dryRun = false;
530
322
  let json = false;
323
+ let all = false;
324
+ let allowStalePlane = false;
325
+ // BAPI-1496 — verification hand-off pass-throughs. Captured, never interpreted:
326
+ // setup-epic owns the vocabulary, the pairing rules, and the policy keys.
327
+ let verificationHandoff;
328
+ const verificationHandoffExceptions = [];
329
+ // BAPI-1510 — drive-only already-merged child controls. Never reach setup argv.
330
+ const keepChildren = [];
331
+ const skipChildren = [];
332
+ let noMergedCheck = false;
531
333
  for (let i = 0; i < argv.length; i++) {
532
334
  const arg = argv[i];
335
+ if (arg === "--all") {
336
+ all = true;
337
+ continue;
338
+ }
339
+ if (arg === "--no-merged-check") {
340
+ noMergedCheck = true;
341
+ continue;
342
+ }
343
+ if (arg === "--keep-child" || arg === "--skip-child") {
344
+ const value = argv[i + 1];
345
+ if (value === undefined || value.startsWith("-")) {
346
+ return { status: "error", message: `${arg} requires a ticket key.` };
347
+ }
348
+ if (!EPIC_KEY_PATTERN.test(value)) {
349
+ return { status: "error", message: `${arg} requires a ticket key like PROJ-123, got '${value}'.` };
350
+ }
351
+ (arg === "--keep-child" ? keepChildren : skipChildren).push(value);
352
+ i++;
353
+ continue;
354
+ }
355
+ const childEq = arg.match(/^(--keep-child|--skip-child)=(.*)$/);
356
+ if (childEq) {
357
+ if (!EPIC_KEY_PATTERN.test(childEq[2])) {
358
+ return { status: "error", message: `${childEq[1]} requires a ticket key like PROJ-123, got '${childEq[2]}'.` };
359
+ }
360
+ (childEq[1] === "--keep-child" ? keepChildren : skipChildren).push(childEq[2]);
361
+ continue;
362
+ }
533
363
  if (arg === "--dry-run") {
534
364
  dryRun = true;
535
365
  continue;
@@ -542,6 +372,11 @@ export function parseDriveEpicArgs(argv) {
542
372
  intoBase = true;
543
373
  continue;
544
374
  }
375
+ // BAPI-1476 — valueless, invocation-only, and never forwarded to setup-epic.
376
+ if (arg === "--allow-stale-plane") {
377
+ allowStalePlane = true;
378
+ continue;
379
+ }
545
380
  // BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
546
381
  // exactly one thing: making the unattended readiness prerequisites
547
382
  // inapplicable. drive-epic reads no policy.
@@ -553,6 +388,29 @@ export function parseDriveEpicArgs(argv) {
553
388
  replacePolicy = true;
554
389
  continue;
555
390
  }
391
+ if (arg === "--verification-handoff" || arg === "--verification-handoff-exception") {
392
+ const value = argv[i + 1];
393
+ if (value === undefined || value.startsWith("-")) {
394
+ return { status: "error", message: `${arg} requires a value.` };
395
+ }
396
+ if (arg === "--verification-handoff")
397
+ verificationHandoff = value;
398
+ else
399
+ verificationHandoffExceptions.push(value);
400
+ i++;
401
+ continue;
402
+ }
403
+ const handoffEq = arg.match(/^(--verification-handoff|--verification-handoff-exception)=(.*)$/);
404
+ if (handoffEq) {
405
+ if (handoffEq[2].trim().length === 0) {
406
+ return { status: "error", message: `${handoffEq[1]} requires a value.` };
407
+ }
408
+ if (handoffEq[1] === "--verification-handoff")
409
+ verificationHandoff = handoffEq[2];
410
+ else
411
+ verificationHandoffExceptions.push(handoffEq[2]);
412
+ continue;
413
+ }
556
414
  if (arg === "--plan-file" ||
557
415
  arg === "--repo" ||
558
416
  arg === "--feature-branch" ||
@@ -628,6 +486,22 @@ export function parseDriveEpicArgs(argv) {
628
486
  "form of the read-only launch preview, and a real launch has no JSON output.",
629
487
  };
630
488
  }
489
+ // BAPI-1510 — contradictory child intent is refused before any file read or launch.
490
+ const overlap = keepChildren.filter((key) => skipChildren.includes(key));
491
+ if (overlap.length > 0) {
492
+ return {
493
+ status: "error",
494
+ message: `${overlap[0]} is named by both --keep-child and --skip-child. ` +
495
+ "Keep it (run it) or skip it, not both.",
496
+ };
497
+ }
498
+ if (noMergedCheck && skipChildren.length > 0) {
499
+ return {
500
+ status: "error",
501
+ message: "--no-merged-check turns off the already-merged check, but --skip-child needs " +
502
+ "that check to confirm the child is merged. Remove one of them.",
503
+ };
504
+ }
631
505
  // BAPI-1009: refused HERE — before access resolution, before the readiness
632
506
  // request, and before any delegation — with the same corrective wording
633
507
  // setup-epic uses, so the two entry points explain one mistake one way.
@@ -670,7 +544,7 @@ export function parseDriveEpicArgs(argv) {
670
544
  }
671
545
  const epicKey = positionals[0];
672
546
  if (!EPIC_KEY_PATTERN.test(epicKey)) {
673
- return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. BAPI-885.` };
547
+ return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. PROJ-885.` };
674
548
  }
675
549
  const options = {
676
550
  epicKey,
@@ -685,8 +559,15 @@ export function parseDriveEpicArgs(argv) {
685
559
  ...(replacePolicy ? { replacePolicy: true } : {}),
686
560
  ...(launchProfile ? { launchProfile } : {}),
687
561
  ...(baseUrl ? { baseUrl } : {}),
562
+ ...(all ? { all: true } : {}),
688
563
  ...(dryRun ? { dryRun: true } : {}),
689
564
  ...(json ? { json: true } : {}),
565
+ ...(allowStalePlane ? { allowStalePlane: true } : {}),
566
+ ...(verificationHandoff !== undefined ? { verificationHandoff } : {}),
567
+ ...(verificationHandoffExceptions.length > 0 ? { verificationHandoffExceptions } : {}),
568
+ ...(keepChildren.length > 0 ? { keepChildren: [...new Set(keepChildren)] } : {}),
569
+ ...(skipChildren.length > 0 ? { skipChildren: [...new Set(skipChildren)] } : {}),
570
+ ...(noMergedCheck ? { noMergedCheck: true } : {}),
690
571
  };
691
572
  return { status: "ok", options };
692
573
  }
@@ -718,7 +599,7 @@ export function selectConductor(readiness) {
718
599
  return {
719
600
  kind: "selected",
720
601
  conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
721
- reason: "conductor readiness is green",
602
+ reason: "Epic Implementer readiness is green",
722
603
  };
723
604
  }
724
605
  return {
@@ -739,7 +620,7 @@ export function selectConductor(readiness) {
739
620
  * no quoting to get wrong.
740
621
  */
741
622
  export function buildSetupEpicArgv(options) {
742
- const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, } = options;
623
+ const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, verificationHandoff, verificationHandoffExceptions, } = options;
743
624
  return [
744
625
  "--epic-key",
745
626
  epicKey,
@@ -760,17 +641,26 @@ export function buildSetupEpicArgv(options) {
760
641
  // the adopted BAPI_BASE_URL instead.
761
642
  ...(launchProfile ? ["--launch-profile", launchProfile] : []),
762
643
  ...(baseUrl ? ["--base-url", baseUrl] : []),
644
+ // BAPI-1496 — the owner's verification hand-off choice, verbatim and only
645
+ // when supplied, so every pre-existing argv stays byte-identical.
646
+ ...(verificationHandoff !== undefined ? ["--verification-handoff", verificationHandoff] : []),
647
+ ...(verificationHandoffExceptions ?? []).flatMap((entry) => ["--verification-handoff-exception", entry]),
763
648
  ];
764
649
  }
765
650
  /**
766
651
  * Render the no-plan stop: what discovery found, how to make a plan, and how to
767
652
  * re-run with one.
768
653
  *
769
- * Precondition: the caller has already ruled out delegation, a not-ready result,
770
- * AND a live run. This runs only after plan discovery came back empty or
654
+ * Normal precondition: the caller has already ruled out delegation, a not-ready
655
+ * result, AND a live run. This runs only after plan discovery came back empty or
771
656
  * ambiguous (BAPI-1285), so it never claims "nothing derives a plan from an epic
772
657
  * key" — something does, and it names exactly what it found.
773
658
  *
659
+ * The `plane_not_running` presentation is the exception to that precondition:
660
+ * the local plane refused connections, so readiness was never read and whether a
661
+ * run for this epic is already live is UNCHECKED. It says so, and tells the
662
+ * operator how to attach.
663
+ *
774
664
  * It offers two ways to re-run and only one of them is a runnable `drive-epic`
775
665
  * command: `/drive-epic-run` is a session slash command, not a second conductor.
776
666
  */
@@ -787,7 +677,20 @@ launch = { profile: null },
787
677
  * BAPI-1285 — what discovery found. Absent reads as "no metadata named this
788
678
  * epic", with the searched directory left as its placeholder.
789
679
  */
790
- discovery) {
680
+ discovery, presentation = "default") {
681
+ // BAPI-1496 — an owner's explicit verification hand-off choice survives the
682
+ // re-run command verbatim. Nothing is derived: no flag, no flag added.
683
+ const handoffSuffix = [
684
+ ...(launch.options?.verificationHandoff !== undefined
685
+ ? ["--verification-handoff", launch.options.verificationHandoff]
686
+ : []),
687
+ ...(launch.options?.verificationHandoffExceptions ?? []).flatMap((entry) => [
688
+ "--verification-handoff-exception",
689
+ entry,
690
+ ]),
691
+ ]
692
+ .map((part) => ` ${shellQuote(part)}`)
693
+ .join("");
791
694
  const invocation = launch.profile !== null
792
695
  ? renderEpicLaunchCommand(launch.profile, {
793
696
  epicKey,
@@ -796,8 +699,8 @@ discovery) {
796
699
  ...(launch.options?.attended ? { attended: true } : {}),
797
700
  ...(launch.options?.intoBase ? { intoBase: true } : {}),
798
701
  ...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
799
- }).replace(`${shellQuote("<path>")}`, "<path>")
800
- : `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
702
+ }).replace(`${shellQuote("<path>")}`, "<path>") + handoffSuffix
703
+ : `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>${handoffSuffix}`;
801
704
  const plansDir = discovery?.epic_plans_dir ?? "{docs_dir}/epic-plans";
802
705
  const outcome = discovery?.status === "plan_ambiguous"
803
706
  ? [
@@ -809,7 +712,7 @@ discovery) {
809
712
  const skipped = discovery !== undefined && discovery.skipped.length > 0
810
713
  ? [
811
714
  "",
812
- "These metadata files could not be used, so they were not counted:",
715
+ "These plan metadata files were missing or could not be used, so they were not counted:",
813
716
  ...discovery.skipped.map((entry) => ` - ${entry.path} (${entry.reason})`),
814
717
  ]
815
718
  : [];
@@ -817,7 +720,9 @@ discovery) {
817
720
  conductor: "v2",
818
721
  invocation,
819
722
  lines: [
820
- `${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
723
+ presentation === "plane_not_running"
724
+ ? `${epicKey}: readiness could not be checked because the local plane is not running and no usable plan could be selected.`
725
+ : `${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
821
726
  "",
822
727
  ...outcome,
823
728
  ...skipped,
@@ -832,19 +737,42 @@ discovery) {
832
737
  "",
833
738
  ` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
834
739
  " sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
835
- " If the epic's tickets already exist, run emit-epic-implementer-bundle",
836
- " finalize instead — it resolves the placeholder keys in an existing",
837
- " sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
838
- " plan is found next time.",
740
+ " For an existing /plan-epic sidecar that has the required bundle inputs and",
741
+ " whose tickets already exist, run emit-epic-implementer-bundle finalize",
742
+ " instead — it resolves the placeholder keys, attaches touched_files, and",
743
+ " writes epic-plan.meta.json so the plan is found next time.",
744
+ " A folder produced by /define-tickets holds a DAG but no metadata file. Use it",
745
+ " directly with --plan-file <that folder's epic-plan.dag.json>.",
839
746
  "",
840
747
  ` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
841
748
  ` or from a shell: ${invocation}`,
842
749
  "",
843
750
  `That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
844
751
  "something you invoke directly.",
752
+ ...(presentation === "plane_not_running"
753
+ ? ["", PLANE_DOWN_LIVE_RUN_CAVEAT]
754
+ : []),
845
755
  ],
846
756
  };
847
757
  }
758
+ /** Appended to the plane-not-running handoff: live-run existence was not checked. */
759
+ const PLANE_DOWN_LIVE_RUN_CAVEAT = "If a run for this epic is already live, bring its plane up and preview again to attach.";
760
+ /** Placeholder selection for the plane-down stop: readiness was never read. */
761
+ const PLANE_DOWN_SELECTION = {
762
+ kind: "selected",
763
+ conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
764
+ reason: "readiness was not checked",
765
+ };
766
+ /**
767
+ * The one text both the preview and a real launch print for the guarded
768
+ * plane-down no-plan stop, so the preview is a verbatim part of the real output.
769
+ */
770
+ function planeDownNoPlanStopText(epicKey, launch, discovery) {
771
+ const text = renderConductorHandoff(PLANE_DOWN_SELECTION, epicKey, launch, discovery, "plane_not_running")
772
+ .lines.join("\n");
773
+ assertSingleConductorInvocation(text);
774
+ return text;
775
+ }
848
776
  /**
849
777
  * Tokens that identify a conductor path in user-facing output. Used by the
850
778
  * output guard and by its test — one list, so a new spelling cannot be added to
@@ -864,7 +792,7 @@ const SETUP_EPIC_SUBCOMMAND = CONDUCTOR_INVOCATION_TOKENS[0];
864
792
  export function assertSingleConductorInvocation(text) {
865
793
  const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
866
794
  if (named.length > 1) {
867
- throw new Error(`drive-epic emitted more than one conductor invocation (${named.join(", ")}). ` +
795
+ throw new Error(`drive-epic emitted more than one launch path (${named.join(", ")}). ` +
868
796
  "Exactly one path may ever be presented.");
869
797
  }
870
798
  // BAPI-1260 — the entry point itself, too. A rendered handoff now carries a
@@ -1120,6 +1048,9 @@ function createDefaultPlanDiscoveryFs() {
1120
1048
  export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalThis.fetch) {
1121
1049
  try {
1122
1050
  const state = await fetchEpicRunState(access, epicKey, fetchImpl);
1051
+ // The same response's engine-reported channel facts; read here so attach,
1052
+ // preview and setup never re-derive a channel from the redacted policy.
1053
+ const notificationSink = state?.notification_sink;
1123
1054
  const run = state
1124
1055
  ?.epic_run;
1125
1056
  if (typeof run?.epic_run_id !== "string" ||
@@ -1131,8 +1062,9 @@ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalTh
1131
1062
  kind: "live",
1132
1063
  epicRunId: run.epic_run_id,
1133
1064
  status: run.status,
1065
+ ticketReview: ticketReviewSummary(run.ticket_review),
1134
1066
  ...(run.policy_json !== undefined
1135
- ? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored") }
1067
+ ? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored", notificationSink) }
1136
1068
  : {}),
1137
1069
  };
1138
1070
  }
@@ -1191,6 +1123,25 @@ export function createDefaultDriveEpicDeps() {
1191
1123
  cwd: () => process.cwd(),
1192
1124
  resolvePlaneEndpoint: () => resolvePlaneServerEndpoint(process.env),
1193
1125
  probePlanePort: (host, port) => probeTcpPort(host, port, PLANE_PORT_PROBE_TIMEOUT_MS),
1126
+ checkExecutorBuildFreshness: (repoRoot) => checkPlaneBuildFreshness(repoRoot, { fs: createPlaneFsDeps() }),
1127
+ collectPlaneFreshness: ({ repoRoot, ownsManifest, expectedPlaneId }) => checkPlaneFreshness(repoRoot, {
1128
+ execFile: createPlaneExecFile(),
1129
+ fileExists: (filePath) => fsStat(filePath).then((entry) => entry.isFile(), () => false),
1130
+ platform: process.platform,
1131
+ readPlaneManifest: (root) => readPlaneManifest(root, createPlaneFsDeps()),
1132
+ }, repoRoot, { ownsManifest, ...(expectedPlaneId !== undefined ? { expectedPlaneId } : {}) }),
1133
+ inspectHostSleep: (repoRoot) => inspectHostSleep(repoRoot, { execFile: createPlaneExecFile(), platform: process.platform }),
1134
+ inspectRequiredRunners: ({ repoRoot, providerRepository, gateProfile }) => inspectRequiredSelfHostedRunners({
1135
+ repoRoot,
1136
+ providerRepository,
1137
+ gateProfile,
1138
+ execFile: createPlaneExecFile(),
1139
+ workflows: createWorkflowSource(),
1140
+ }),
1141
+ inspectEpicBranchBehind: ({ repoRoot, branch, baseBranch }) => inspectEpicBranchBehindBase({ repoRoot, branch, baseBranch, execFile: createPlaneExecFile() }),
1142
+ fetchUsageAdvisory: (access) => fetchRepositoryUsageAdvisory(access),
1143
+ inspectWorkerDatabase: createWorkerDatabaseDoctorInspector({ env: process.env, cwd: process.cwd() }),
1144
+ cleanupTerminalRunPlane: (input) => cleanupTerminalRunPlane(input, createTerminalRunCleanupDeps(process.env)),
1194
1145
  readRunState: async (access, epicRunId) => {
1195
1146
  const state = await fetchEpicRunState(access, epicRunId, globalThis.fetch);
1196
1147
  const run = state.epic_run;
@@ -1242,8 +1193,28 @@ export function createDefaultDriveEpicDeps() {
1242
1193
  // preview must not call.
1243
1194
  inspectRunExecutorPrecondition: async (cwd) => {
1244
1195
  const result = await prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps());
1245
- return result.ok ? { ok: true } : { ok: false, message: result.message };
1196
+ // BAPI-1452: the Git top level rides along so the dry run can inspect the
1197
+ // same clone a real launch would, without ever preparing it.
1198
+ return result.ok
1199
+ ? { ok: true, repoRoot: result.prepared.repoRoot }
1200
+ : { ok: false, message: result.message };
1246
1201
  },
1202
+ probeLaunchMergeCredential: (args) => probeLocalMergeCapability(args),
1203
+ // BAPI-1461: the local executor this launch starts runs in the person's own
1204
+ // session, so its credential may come from the repository's stored entry.
1205
+ resolveLaunchMergeCredential: ({ kind, repository }) => resolveMergeCredentialAtAction(process.env, kind, {
1206
+ allowStoredEntry: true,
1207
+ providerRepository: repository,
1208
+ storeDeps: nodeGithubMergeCredentialReadDeps(process.env),
1209
+ }),
1210
+ launchReadinessLog: (message) => console.error(message),
1211
+ // Read-only filesystem boundary: `readFile`, `stat`, and a W_OK `access`
1212
+ // check. Nothing here can create or write the exclude.
1213
+ inspectWorktreeExclude: (repoRoot) => resolveWorktreeSettingsExclude(repoRoot, {
1214
+ readFile: (filePath) => readFile(filePath, "utf8"),
1215
+ stat: async (filePath) => ({ mode: (await fsStat(filePath)).mode }),
1216
+ accessWritable: (target) => fsAccess(target, fsConstants.W_OK),
1217
+ }),
1247
1218
  prepareRunExecutor: (cwd) => prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps()),
1248
1219
  ensureRunExecutor: (args) => ensureRunExecutorDefault(args, createDefaultRunExecutorLaunchDeps()),
1249
1220
  // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
@@ -1292,7 +1263,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1292
1263
  // child processes' BAPI_BASE_URL.
1293
1264
  const accessResult = await deps.resolveAccess(repo, undefined, launch);
1294
1265
  if (!accessResult.ok) {
1295
- return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
1266
+ return escalate(deps, epicKey, `Epic Implementer readiness could not be read: ${accessResult.error}`);
1296
1267
  }
1297
1268
  const launchProfile = accessResult.profile ?? null;
1298
1269
  deps.adoptLaunchTarget?.(accessResult.access.baseUrl);
@@ -1310,6 +1281,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1310
1281
  access: accessResult.access,
1311
1282
  profile: launchProfile,
1312
1283
  targetSource: accessResult.targetSource ?? "default",
1284
+ freshness: createPlaneFreshnessCache(),
1285
+ launchCredential: {},
1313
1286
  };
1314
1287
  // BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
1315
1288
  // discovered plan must count as a supplied one for the BAPI-1153 cold-start
@@ -1320,11 +1293,16 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1320
1293
  // escalates exactly as before, and the live-run read stays after readiness.
1321
1294
  let discovered = null;
1322
1295
  let docsRootUnresolved = false;
1296
+ // BAPI-1532 — whether the effective plan was FOUND rather than supplied. A
1297
+ // discovered plan is a convenience, not approved input: it may still be used to
1298
+ // create a run, but a live run is attached to first and the plan stays unused.
1299
+ let planWasDiscovered = false;
1323
1300
  if (planFile === undefined) {
1324
1301
  const docs = await deps.resolveDocsDir();
1325
1302
  if (docs.ok) {
1326
1303
  discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
1327
1304
  if (discovered.status === "found") {
1305
+ planWasDiscovered = true;
1328
1306
  ctx = { ...ctx, options: { ...ctx.options, planFile: discovered.path } };
1329
1307
  }
1330
1308
  }
@@ -1348,6 +1326,10 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1348
1326
  readiness = await deps.readReadiness(accessResult.access);
1349
1327
  }
1350
1328
  catch (err) {
1329
+ // BAPI-1455 — a proven-incompatible local schema is a terminal refusal,
1330
+ // not an unknown: the server read the catalog and named what is missing.
1331
+ if (isSchemaIncompatibleError(err))
1332
+ return refuse(deps, formatSchemaIncompatibleRefusal(epicKey, err));
1351
1333
  // BAPI-1153 — the cold-start case. With a plan file, a readiness read that
1352
1334
  // failed at the TRANSPORT against the plane's own endpoint, on a port that
1353
1335
  // actively refuses connections, is not "unknown": it is "no server yet".
@@ -1376,9 +1358,33 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1376
1358
  }
1377
1359
  }
1378
1360
  }
1379
- return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
1361
+ // The narrow no-plan stop: a local, non-durable plane that refuses
1362
+ // connections, with no usable plan supplied or discovered. Readiness was
1363
+ // never read, so this is the existing stop outcome — not a new refusal.
1364
+ // Nothing is started or created.
1365
+ if (ctx.options.planFile === undefined &&
1366
+ planeTarget &&
1367
+ err instanceof ConductorBridgeApiError &&
1368
+ err.kind === "network" &&
1369
+ launchProfile?.executionMode !== "durable" &&
1370
+ discovered !== null &&
1371
+ (discovered.status === "plan_not_found" || discovered.status === "plan_ambiguous")) {
1372
+ const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
1373
+ if (endpoint !== null) {
1374
+ const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
1375
+ if (probe.kind === "refused") {
1376
+ deps.errorLog(planeDownNoPlanStopText(epicKey, { profile: launchProfile, options: parsed.options }, discovered));
1377
+ return 1;
1378
+ }
1379
+ }
1380
+ }
1381
+ return escalate(deps, epicKey, `Epic Implementer readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
1380
1382
  }
1381
- if (ctx.options.planFile !== undefined) {
1383
+ // BAPI-1532 — an EXPLICIT plan selects its setup now. A discovered plan does
1384
+ // not: plan selection, approval, and the create-time escalation-channel checks
1385
+ // run only on the create path, after the live-run read below, so attaching to a
1386
+ // live run never depends on them.
1387
+ if (ctx.options.planFile !== undefined && !planWasDiscovered) {
1382
1388
  const prepared = await prepareSetupSelection(ctx);
1383
1389
  if (!prepared.ok) {
1384
1390
  return refuse(deps, [
@@ -1417,8 +1423,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1417
1423
  if (live.kind === "unknown")
1418
1424
  return refuse(deps, liveRunUnknownRefusal(epicKey, live.reason).lines);
1419
1425
  if (discovered.status === "found") {
1420
- // The plan was folded into `ctx.options` before readiness; from here it is
1421
- // indistinguishable from a supplied `--plan-file`.
1426
+ if (live.kind === "live") {
1427
+ // BAPI-1532 — attach FIRST. The discovered plan is dropped from the
1428
+ // effective options, so nothing below can treat it as approved input,
1429
+ // and the local file is left untouched.
1430
+ const { planFile: _unusedDiscoveredPlan, ...attachOptions } = ctx.options;
1431
+ return await attachToLiveRun({ ...ctx, options: attachOptions }, readiness, live, discovered.path);
1432
+ }
1433
+ // The create path: setup selection runs now. The plan was folded into
1434
+ // `ctx.options` before readiness; from here it is indistinguishable from
1435
+ // a supplied `--plan-file`.
1436
+ const prepared = await prepareSetupSelection(ctx);
1437
+ if (!prepared.ok) {
1438
+ return refuse(deps, [
1439
+ `${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
1440
+ "Nothing was created or started.",
1441
+ ]);
1442
+ }
1443
+ ctx = { ...ctx, handoff: prepared.handoff };
1422
1444
  await announceFoundPlan();
1423
1445
  }
1424
1446
  else if (live.kind === "live") {
@@ -1461,10 +1483,33 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1461
1483
  // Otherwise — no plane here, or one owned by a sibling worktree — the
1462
1484
  // run gets a run-scoped executor. The preflight runs only when a
1463
1485
  // manifest says there is a plane in this checkout to attach to.
1486
+ // BAPI-1496: a plane bound to a finished run is torn down BEFORE it can
1487
+ // be attached to. Once it is gone, nothing serves this endpoint, so the
1488
+ // launch cold-starts a fresh plane rather than creating against the
1489
+ // stopped API or trusting the readiness read from it.
1490
+ const cleaned = await cleanUpTerminalRunPlane(ctx, deps.cwd());
1491
+ if (cleaned?.kind === "completed") {
1492
+ const endpoint = planeEndpointForBaseUrl(deps, ctx.access.baseUrl);
1493
+ const probe = endpoint
1494
+ ? await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }))
1495
+ : null;
1496
+ if (probe?.kind === "refused")
1497
+ return await coldStartPlane(ctx);
1498
+ }
1464
1499
  const attachable = await findAttachablePlane(ctx);
1465
1500
  if (attachable !== null) {
1466
1501
  return await attachToPlane(ctx, attachable, readiness);
1467
1502
  }
1503
+ // BAPI-1476: no manifest in this checkout belongs to the plane serving the
1504
+ // endpoint (a sibling worktree's, or none), so the snapshot is unverified
1505
+ // here; the database check still runs. Decided before setup-epic.
1506
+ const staleSibling = await planeFreshnessCreateGuard(ctx, {
1507
+ repoRoot: deps.cwd(),
1508
+ ownsManifest: false,
1509
+ });
1510
+ if (staleSibling !== null)
1511
+ return staleSibling;
1512
+ await surfaceWorkerDatabaseAdvisory(ctx);
1468
1513
  return await createRunWithRunExecutor(ctx, readiness);
1469
1514
  }
1470
1515
  // Operator-blocked: ONE named reason, and nothing is started. `plane up`
@@ -1472,8 +1517,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1472
1517
  // so bringing it up would leave a running plane behind and change nothing.
1473
1518
  if (classification.kind === "operator_blocked") {
1474
1519
  const refusal = [
1475
- `${epicKey}: the engine path still needs ${classification.describe} ` +
1476
- `(${classification.id}).`,
1520
+ `${epicKey}: the engine path still needs ${classification.describe}.`,
1477
1521
  "",
1478
1522
  "This is not something starting the runtime can fix, so nothing was started.",
1479
1523
  // The requirement's OWN remedy when it has one. Telling an operator to
@@ -1497,6 +1541,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1497
1541
  }
1498
1542
  // Runtime-only: this invocation composes bring-up with run creation.
1499
1543
  const repoRoot = deps.cwd();
1544
+ // BAPI-1496: a plane bound to a finished run is torn down before preflight.
1545
+ await cleanUpTerminalRunPlane(ctx, repoRoot);
1500
1546
  const preflight = await deps.runPlanePreflight(repoRoot);
1501
1547
  // BAPI-1153 — ATTACH. A port serving this repository's own live, healthy
1502
1548
  // plane is not a blocker when the Bridge API this command talks to IS that
@@ -1517,7 +1563,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1517
1563
  });
1518
1564
  if (precondition) {
1519
1565
  const lines = [
1520
- `${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
1566
+ `${epicKey}: cannot start the Epic Implementer runtime — ${precondition.check} failed.`,
1521
1567
  formatPlaneDiagnostic(precondition),
1522
1568
  ];
1523
1569
  // BAPI-1153: an occupied port names WHICH kind of occupant it is and its
@@ -1548,7 +1594,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1548
1594
  // The new order is the only one that can work: the server has to be up
1549
1595
  // before a run can be created, and the run has to exist before a lane can
1550
1596
  // be scoped to it.
1551
- deps.log(`${epicKey}: the conductor runtime is not live; starting its control plane before creating the run.`);
1597
+ deps.log(`${epicKey}: the Epic Implementer runtime is not live; starting its control plane before creating the run.`);
1552
1598
  // The SAME preflight result is handed to the launch, so the port that was
1553
1599
  // probed is the port that gets launched and the two cannot disagree.
1554
1600
  const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
@@ -1562,6 +1608,12 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1562
1608
  // the detached runtime is its own process-group leader and outlives us by
1563
1609
  // design, which is what "the plane is left running" means.
1564
1610
  void controlPlane.lifetime.catch(() => undefined);
1611
+ // BAPI-1476: the plane this invocation just started, checked before any
1612
+ // setup or run-create. A refusal leaves the plane running and says so.
1613
+ const staleLaunched = await planeFreshnessCreateGuard(ctx, { repoRoot, ownsManifest: true, planeId: controlPlane.planeId }, LAUNCHED_PLANE_LEFT_RUNNING);
1614
+ if (staleLaunched !== null)
1615
+ return staleLaunched;
1616
+ await surfaceWorkerDatabaseAdvisory(ctx);
1565
1617
  // Readiness is NOT re-read as a gate here. A successful bring-up IS the
1566
1618
  // runtime fact: the launch already waited for this plane's own reconciler
1567
1619
  // heartbeat. (`reconciler_live` now reads the deployment heartbeat, but the
@@ -1573,7 +1625,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1573
1625
  repoRoot,
1574
1626
  planeId: controlPlane.planeId,
1575
1627
  awaitPlaneReady: controlPlane.awaitPlaneReady,
1576
- });
1628
+ }, readiness);
1577
1629
  }
1578
1630
  // No plan file, and readiness is not green: v2 is not ready and there is no
1579
1631
  // second conductor to offer. Report it as a not-ready refusal on stderr with
@@ -1583,8 +1635,9 @@ export async function runDriveEpicCli(argv, overrides = {}) {
1583
1635
  `${epicKey}: ${selection.reason}.`,
1584
1636
  "",
1585
1637
  "The v2 engine is not ready to drive this epic, and there is no alternative",
1586
- "conductor to fall back to. Resolve conductor readiness (`install-bridge",
1587
- "conductor`, then `doctor`) and run drive-epic again.",
1638
+ "engine to fall back to. Resolve Epic Implementer readiness (`install conductor`,",
1639
+ "then `doctor`) and run drive-epic again.",
1640
+ `See ${CONSUMER_GUIDE_POINTER}.`,
1588
1641
  ].join("\n");
1589
1642
  assertSingleConductorInvocation(refusal);
1590
1643
  deps.errorLog(refusal);
@@ -1633,7 +1686,7 @@ async function surfaceContainmentAdvisory(deps, planFile) {
1633
1686
  * A constant so the preview's `readiness.remedy` is the very line the real
1634
1687
  * refusal prints, not a second copy that can drift.
1635
1688
  */
1636
- const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve conductor readiness.";
1689
+ const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve Epic Implementer readiness.";
1637
1690
  /** The reason id the preview reports for an unknown readiness read. */
1638
1691
  const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
1639
1692
  /**
@@ -1642,8 +1695,9 @@ const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
1642
1695
  * the preview cannot disagree.
1643
1696
  */
1644
1697
  function operatorBlockedRemedy(classification) {
1645
- return (classification.remedy ??
1646
- `Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again.`);
1698
+ const remedy = classification.remedy ??
1699
+ `Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again.`;
1700
+ return `${remedy} See ${CONSUMER_GUIDE_POINTER}.`;
1647
1701
  }
1648
1702
  /**
1649
1703
  * The escalation branch. Prints NO conductor invocation at all — naming a
@@ -1652,10 +1706,10 @@ function operatorBlockedRemedy(classification) {
1652
1706
  */
1653
1707
  function escalate(deps, epicKey, reason) {
1654
1708
  const lines = [
1655
- `Cannot determine which conductor owns ${epicKey}: ${reason}`,
1709
+ `Cannot determine which engine owns ${epicKey}: ${reason}`,
1656
1710
  "",
1657
1711
  "This is not a not-ready result — it is an unknown one, and starting the wrong",
1658
- "conductor on an epic wedges it permanently.",
1712
+ "engine on an epic wedges it permanently.",
1659
1713
  ESCALATE_REMEDY,
1660
1714
  ];
1661
1715
  const text = lines.join("\n");
@@ -1664,6 +1718,10 @@ function escalate(deps, epicKey, reason) {
1664
1718
  return 1;
1665
1719
  }
1666
1720
  /** Resolve policy intent once for the setup delegation, after a fresh run read. */
1721
+ /** Narrow an effective-policy channel label to the closed identity set. */
1722
+ function channelIdentityOf(label) {
1723
+ return label === "configured webhook" || label === "account email" ? label : "unavailable";
1724
+ }
1667
1725
  async function prepareSetupSelection(ctx, coldStart = false) {
1668
1726
  const live = coldStart ? { kind: "none" } : await ctx.deps.readLiveEpicRun(ctx.access, ctx.epicKey);
1669
1727
  if (live.kind === "conflict")
@@ -1701,12 +1759,157 @@ async function prepareSetupSelection(ctx, coldStart = false) {
1701
1759
  policy,
1702
1760
  escalationChannel: pinnedPolicy
1703
1761
  ? policyDeclaresNotifyWebhook(pinnedPolicy) ? "configured webhook" : "account email"
1704
- : live.kind === "live" ? "unavailable" : "account email",
1762
+ : live.kind === "live" ? channelIdentityOf(live.effectivePolicy?.escalation_channel) : "account email",
1705
1763
  });
1706
1764
  if (pinnedPolicy)
1707
1765
  pinEpicLaunchPolicy(selection, pinnedPolicy);
1708
1766
  return { ok: true, handoff: { selection, access: ctx.access, profile: ctx.profile } };
1709
1767
  }
1768
+ // ---------------------------------------------------------------------------
1769
+ // BAPI-1510 — already-merged child detection
1770
+ // ---------------------------------------------------------------------------
1771
+ /** The refusal reason shared by the preview and a real create when nothing is left to run. */
1772
+ export const EPIC_ALREADY_COMPLETE_REASON_ID = "epic-already-complete";
1773
+ const EPIC_ALREADY_COMPLETE_REMEDY = "Every child in this plan is already merged and on the indexed commit, so there is nothing to run. " +
1774
+ "If you still want a child to run, pass --keep-child <KEY> for it.";
1775
+ /** The fixed text a failed explicit skip is refused with. */
1776
+ function skipRefusalRemedy(refusals) {
1777
+ const keys = (reason) => refusals.filter((r) => r.reason === reason).map((r) => r.ticketKey);
1778
+ const parts = [];
1779
+ const notInPlan = keys("not_in_plan");
1780
+ if (notInPlan.length > 0)
1781
+ parts.push(`${notInPlan.join(", ")} not in this plan`);
1782
+ const unavailable = keys("check_unavailable");
1783
+ if (unavailable.length > 0) {
1784
+ parts.push(`${unavailable.join(", ")} could not be confirmed because the already-merged check could not run`);
1785
+ }
1786
+ const weak = keys("not_skippable");
1787
+ if (weak.length > 0) {
1788
+ parts.push(`${weak.join(", ")} not currently shown as merged on the indexed commit`);
1789
+ }
1790
+ return (`--skip-child was refused: ${parts.join("; ")}. ` +
1791
+ "Remove the flag for those children (they will run), or fix the cause and try again.");
1792
+ }
1793
+ /** Parse a plan sidecar into its DAG and child keys, or `null` when it is not usable for the check. */
1794
+ async function readPlanChildren(deps, planPath) {
1795
+ try {
1796
+ const parsed = JSON.parse(await deps.readPlanDag(planPath));
1797
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
1798
+ return null;
1799
+ const nodes = parsed.nodes;
1800
+ if (!Array.isArray(nodes))
1801
+ return null;
1802
+ const keys = [];
1803
+ for (const node of nodes) {
1804
+ const key = node?.ticket_key;
1805
+ if (typeof key !== "string" || !EPIC_KEY_PATTERN.test(key))
1806
+ return null;
1807
+ keys.push(key);
1808
+ }
1809
+ if (new Set(keys).size !== keys.length)
1810
+ return null;
1811
+ return { plan: parsed, keys };
1812
+ }
1813
+ catch {
1814
+ return null;
1815
+ }
1816
+ }
1817
+ function notChecked(reason, base = {}) {
1818
+ return {
1819
+ state: { status: "not_checked", reason, children: [] },
1820
+ childKeys: [],
1821
+ plan: null,
1822
+ cutCommitSha: null,
1823
+ skipKeys: new Set(),
1824
+ confirmedKeys: new Set(),
1825
+ refusals: [],
1826
+ ...base,
1827
+ };
1828
+ }
1829
+ /**
1830
+ * Classify the plan's children against the canonical indexed commit and decide the
1831
+ * skip set. Read-only; every failure degrades to "not checked, keep every child",
1832
+ * except that an explicit `--skip-child` it cannot support becomes a refusal.
1833
+ * Detection that is turned off (`--no-merged-check`) never reads anything.
1834
+ */
1835
+ async function evaluateMergedChildren(ctx, planPath) {
1836
+ const { deps, options, access, epicKey } = ctx;
1837
+ if (options.noMergedCheck === true)
1838
+ return notChecked("disabled");
1839
+ const children = await readPlanChildren(deps, planPath);
1840
+ if (children === null)
1841
+ return notChecked("no_plan");
1842
+ const base = { childKeys: children.keys, plan: children.plan };
1843
+ const skipChildren = options.skipChildren ?? [];
1844
+ const keepChildren = options.keepChildren ?? [];
1845
+ const failed = (reason, cutCommitSha) => ({
1846
+ ...notChecked(reason, base),
1847
+ cutCommitSha,
1848
+ refusals: selectSkips({ planKeys: children.keys, results: null, keepChildren, skipChildren }).refusals,
1849
+ });
1850
+ let cutCommitSha;
1851
+ try {
1852
+ const truth = await (deps.readCanonicalParseTruth
1853
+ ? deps.readCanonicalParseTruth(access)
1854
+ : readSetupCanonicalParseTruth(access, globalThis.fetch));
1855
+ if (!truth.ok)
1856
+ return failed("cut_commit_unavailable", null);
1857
+ cutCommitSha = truth.cutCommitSha;
1858
+ }
1859
+ catch {
1860
+ return failed("cut_commit_unavailable", null);
1861
+ }
1862
+ const classify = deps.classifyChildMergeStatus
1863
+ ? (request) => deps.classifyChildMergeStatus(access, request)
1864
+ : (request) => classifyChildMergeStatusClient(access, request);
1865
+ const checked = await classifyPlanChildren(classify, epicKey, children.keys, cutCommitSha);
1866
+ if (!checked.ok)
1867
+ return failed("check_failed", cutCommitSha);
1868
+ const selection = selectSkips({
1869
+ planKeys: children.keys,
1870
+ results: checked.results,
1871
+ keepChildren,
1872
+ skipChildren,
1873
+ });
1874
+ return {
1875
+ state: { status: "checked", children: buildAlreadyDoneEntries(checked.results, selection.skipKeys) },
1876
+ childKeys: children.keys,
1877
+ plan: children.plan,
1878
+ cutCommitSha,
1879
+ skipKeys: selection.skipKeys,
1880
+ confirmedKeys: selection.confirmedKeys,
1881
+ refusals: selection.refusals,
1882
+ };
1883
+ }
1884
+ /** The PR number for a skipped child, only when it is a valid positive integer. */
1885
+ function skippedChildPr(state, key) {
1886
+ const entry = state.children.find((child) => child.ticket_key === key);
1887
+ return entry !== undefined && entry.pr_number !== null && Number.isSafeInteger(entry.pr_number) && entry.pr_number > 0
1888
+ ? entry.pr_number
1889
+ : null;
1890
+ }
1891
+ /** Post-create audit recording: best effort, one plain warning, never changes the outcome. */
1892
+ async function recordSkippedChildrenBestEffort(ctx, epicRunId, skipped) {
1893
+ const { deps, access } = ctx;
1894
+ const children = skipped.map((s) => ({ ticketKey: s.key, operatorConfirmed: s.confirmed }));
1895
+ let allRecorded = true;
1896
+ try {
1897
+ for (let i = 0; i < children.length; i += CHILD_MERGE_STATUS_BATCH_SIZE) {
1898
+ const batch = children.slice(i, i + CHILD_MERGE_STATUS_BATCH_SIZE);
1899
+ const response = deps.recordSkippedChildren
1900
+ ? await deps.recordSkippedChildren(access, epicRunId, batch)
1901
+ : await recordSkippedChildrenClient(access, epicRunId, batch);
1902
+ if (response.results.some((r) => r.outcome === "refused"))
1903
+ allRecorded = false;
1904
+ }
1905
+ }
1906
+ catch {
1907
+ allRecorded = false;
1908
+ }
1909
+ if (!allRecorded) {
1910
+ deps.errorLog("The run was created, but the skipped-child record could not be saved.");
1911
+ }
1912
+ }
1710
1913
  async function runSelectedSetupEpic(ctx) {
1711
1914
  const prepared = ctx.handoff
1712
1915
  ? { ok: true, handoff: ctx.handoff }
@@ -1715,7 +1918,61 @@ async function runSelectedSetupEpic(ctx) {
1715
1918
  ctx.deps.errorLog(`${ctx.epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`);
1716
1919
  return { exitCode: 1 };
1717
1920
  }
1718
- const result = await ctx.deps.runSetupEpic(buildSetupEpicArgv(ctx.options), prepared.handoff);
1921
+ const { deps, options, epicKey } = ctx;
1922
+ // BAPI-1510 — fresh classification immediately before setup. Preview verdicts are
1923
+ // never consumed: each create starts from the ORIGINAL plan and re-checks.
1924
+ const planPath = options.planFile;
1925
+ if (planPath === undefined || options.noMergedCheck === true) {
1926
+ return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
1927
+ }
1928
+ const evaluation = await evaluateMergedChildren(ctx, planPath);
1929
+ if (evaluation.refusals.length > 0) {
1930
+ deps.errorLog(`${epicKey}: REFUSED (skip-child-refused) — ${skipRefusalRemedy(evaluation.refusals)}`);
1931
+ deps.errorLog("No run was created.");
1932
+ return { exitCode: 1 };
1933
+ }
1934
+ if (evaluation.state.status === "not_checked") {
1935
+ if (evaluation.state.reason === "cut_commit_unavailable" || evaluation.state.reason === "check_failed") {
1936
+ deps.errorLog(`${epicKey}: could not check for already-merged children; every child stays in the plan.`);
1937
+ }
1938
+ return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
1939
+ }
1940
+ if (evaluation.skipKeys.size === 0 || evaluation.plan === null || evaluation.cutCommitSha === null) {
1941
+ return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
1942
+ }
1943
+ if (evaluation.skipKeys.size >= evaluation.childKeys.length) {
1944
+ deps.errorLog(`${epicKey}: REFUSED (${EPIC_ALREADY_COMPLETE_REASON_ID}) — ${EPIC_ALREADY_COMPLETE_REMEDY}`);
1945
+ deps.errorLog("No run was created.");
1946
+ return { exitCode: 1 };
1947
+ }
1948
+ const skipped = evaluation.childKeys
1949
+ .filter((key) => evaluation.skipKeys.has(key))
1950
+ .map((key) => ({ key, confirmed: evaluation.confirmedKeys.has(key) }));
1951
+ for (const { key, confirmed } of skipped) {
1952
+ const pr = skippedChildPr(evaluation.state, key);
1953
+ deps.log(`${epicKey}: ${confirmed ? "skipping (confirmed by you)" : "skipping already-merged child"} ${key}` +
1954
+ `${pr !== null ? ` (#${pr})` : ""}`);
1955
+ }
1956
+ const trimmed = trimDonePlanNodes(evaluation.plan, evaluation.skipKeys);
1957
+ const withPath = deps.withPlanPath ?? withPlanPath;
1958
+ const result = await withPath({
1959
+ originalPath: planPath,
1960
+ trimmedJson: JSON.stringify(trimmed, null, 2),
1961
+ skipped: true,
1962
+ warn: (message) => deps.errorLog(message),
1963
+ }, (trimmedPath) => deps.runSetupEpic(buildSetupEpicArgv({ ...options, planFile: trimmedPath }), {
1964
+ ...prepared.handoff,
1965
+ expectedCutCommitSha: evaluation.cutCommitSha,
1966
+ attachOnlyOnReuse: true,
1967
+ }));
1968
+ if (result.reusedExistingRun === true) {
1969
+ // Reuse or race adoption: the trimmed plan was never applied and nothing is recorded.
1970
+ deps.log(`${epicKey}: an existing run was found; the already-merged check was not applied and existing runs are unchanged.`);
1971
+ return result;
1972
+ }
1973
+ if (result.epicRunId !== undefined && result.runCreated === true && result.planStored === true) {
1974
+ await recordSkippedChildrenBestEffort(ctx, result.epicRunId, skipped);
1975
+ }
1719
1976
  return result;
1720
1977
  }
1721
1978
  /** Emit one refusal, guarded so it can never name a second conductor. */
@@ -1725,6 +1982,200 @@ function refuse(deps, lines) {
1725
1982
  deps.errorLog(text);
1726
1983
  return 1;
1727
1984
  }
1985
+ /** The launch evidence rows exist only for an unattended launch. */
1986
+ function isUnattendedLaunch(options) {
1987
+ return options.attended !== true && options.intoBase !== true;
1988
+ }
1989
+ /** The launch row ids a launch can be refused on, without their namespace. */
1990
+ const LAUNCH_REFUSAL_IDS = ["merge-credential-capability", "worktree-preparation", ...Object.values(READINESS_PRE_CREATE_CODES)];
1991
+ /**
1992
+ * The credential evidence. Probes ONLY a locally owned executor's credential in
1993
+ * `local` / `machine_token` mode, against the server's explicit `owner/name`;
1994
+ * never the launcher's unrelated credential for an external executor, and never
1995
+ * anything for `github_actions`. Every gap is a value-free warning reason.
1996
+ */
1997
+ async function collectCredentialEvidence(deps, readiness, topology, box) {
1998
+ const mode = readiness.resolved_merge_execution_mode;
1999
+ if (mode === undefined)
2000
+ return { kind: "unverified", reason: "server_fields_missing" };
2001
+ if (mode === null)
2002
+ return { kind: "unverified", reason: "mode_unresolved" };
2003
+ if (mode === "github_actions")
2004
+ return { kind: "github_actions" };
2005
+ if (topology.kind === "plane_lane")
2006
+ return { kind: "unverified", reason: "external_executor_plane", mode };
2007
+ if (topology.kind === "durable_profile") {
2008
+ return { kind: "unverified", reason: "external_executor_durable", mode };
2009
+ }
2010
+ const repository = readiness.provider_repository;
2011
+ if (repository === undefined)
2012
+ return { kind: "unverified", reason: "server_fields_missing", mode };
2013
+ if (!isProviderRepositoryIdentity(repository)) {
2014
+ return { kind: "unverified", reason: "repository_unresolved", mode };
2015
+ }
2016
+ if (deps.probeLaunchMergeCredential === undefined) {
2017
+ return { kind: "unverified", reason: "probe_unavailable", mode };
2018
+ }
2019
+ // BAPI-1461: resolve once, with the stored-entry fallback, and probe exactly
2020
+ // the credential the executor will be handed — never a second env lookup.
2021
+ const resolved = await resolveLaunchCredentialOnce(deps, mode, repository, box);
2022
+ let verdict;
2023
+ try {
2024
+ verdict = classifyLocalGhPreflight(await deps.probeLaunchMergeCredential({
2025
+ kind: mode,
2026
+ repository,
2027
+ // A store-sourced credential is handed over explicitly; an env one is the
2028
+ // same value the probe resolves itself, so its call is unchanged.
2029
+ ...(resolved !== undefined && resolved.fromStore
2030
+ ? { resolved: { credential: resolved.credential, invalid: resolved.invalid } }
2031
+ : {}),
2032
+ }));
2033
+ }
2034
+ catch {
2035
+ // Nothing from the thrown value is kept: an unexpected probe failure is
2036
+ // simply unavailable evidence.
2037
+ return { kind: "unverified", reason: "probe_unavailable", mode };
2038
+ }
2039
+ switch (verdict) {
2040
+ case "passed":
2041
+ return { kind: "verified", mode, repository };
2042
+ case "credential_missing":
2043
+ return { kind: "credential_missing", mode };
2044
+ case "credential_invalid":
2045
+ return { kind: "credential_invalid", mode };
2046
+ case "no_access":
2047
+ return { kind: "no_access", mode, repository };
2048
+ case "gh_unavailable":
2049
+ return { kind: "unverified", reason: "gh_unavailable", mode };
2050
+ case "probe_unavailable":
2051
+ return { kind: "unverified", reason: "probe_unavailable", mode };
2052
+ }
2053
+ }
2054
+ /**
2055
+ * BAPI-1461 — resolve the launch's merge credential once and remember it in the
2056
+ * launch's box. `undefined` means no resolver was supplied (or it failed), so the
2057
+ * probe falls back to its own environment lookup and nothing is handed over.
2058
+ */
2059
+ async function resolveLaunchCredentialOnce(deps, kind, repository, box) {
2060
+ if (deps.resolveLaunchMergeCredential === undefined)
2061
+ return undefined;
2062
+ let resolution;
2063
+ try {
2064
+ resolution = await deps.resolveLaunchMergeCredential({ kind, repository });
2065
+ }
2066
+ catch {
2067
+ return undefined;
2068
+ }
2069
+ const credential = resolution.status === "resolved" ? resolution.credential : null;
2070
+ if (box !== undefined)
2071
+ box.resolved = credential;
2072
+ return {
2073
+ credential,
2074
+ invalid: resolution.status === "invalid",
2075
+ fromStore: (resolution.status === "resolved" || resolution.status === "invalid") && resolution.source === "store",
2076
+ };
2077
+ }
2078
+ /**
2079
+ * BAPI-1461 — the credential to hand a run-scoped executor: the one this launch
2080
+ * already resolved, or (on the live-run attach path, which runs no launch gate)
2081
+ * a fresh resolution for a locally owned, `local`-mode executor. Only the local
2082
+ * kind is ever handed over; nothing for machine-user or GitHub Actions mode.
2083
+ */
2084
+ async function launchCredentialForExecutor(ctx, readiness) {
2085
+ const box = ctx.launchCredential;
2086
+ if (box !== undefined && box.resolved !== undefined) {
2087
+ return box.resolved?.kind === "local" ? box.resolved : null;
2088
+ }
2089
+ if (readiness.resolved_merge_execution_mode !== "local")
2090
+ return null;
2091
+ const repository = readiness.provider_repository;
2092
+ if (typeof repository !== "string" || !isProviderRepositoryIdentity(repository))
2093
+ return null;
2094
+ const resolved = await resolveLaunchCredentialOnce(ctx.deps, "local", repository, box);
2095
+ return resolved?.credential ?? null;
2096
+ }
2097
+ /**
2098
+ * The worktree evidence, from Child 1's NON-WRITING resolver against the clone
2099
+ * the executor will own. Blocks only when the exclude is proven neither present
2100
+ * nor writable; anything the resolver cannot establish is a warning. The
2101
+ * absolute exclude path never leaves this function.
2102
+ */
2103
+ async function collectWorktreeEvidence(deps, topology) {
2104
+ if (topology.kind === "plane_lane")
2105
+ return { kind: "unverified", reason: "external_executor_plane" };
2106
+ if (topology.kind === "durable_profile")
2107
+ return { kind: "unverified", reason: "external_executor_durable" };
2108
+ if (topology.repoRoot === null || deps.inspectWorktreeExclude === undefined) {
2109
+ return { kind: "unverified", reason: "inspection_unavailable" };
2110
+ }
2111
+ let resolution;
2112
+ try {
2113
+ resolution = await deps.inspectWorktreeExclude(topology.repoRoot);
2114
+ }
2115
+ catch {
2116
+ return { kind: "unverified", reason: "inspection_unavailable" };
2117
+ }
2118
+ if (resolution.kind !== "resolved")
2119
+ return { kind: "unverified", reason: "inspection_unavailable" };
2120
+ if (resolution.present || resolution.writable)
2121
+ return { kind: "ready", present: resolution.present };
2122
+ return { kind: "blocked" };
2123
+ }
2124
+ /** Collect both halves of the launch evidence. */
2125
+ async function collectLaunchEvidence(deps, readiness, topology, box) {
2126
+ return {
2127
+ credential: await collectCredentialEvidence(deps, readiness, topology, box),
2128
+ worktree: await collectWorktreeEvidence(deps, topology),
2129
+ };
2130
+ }
2131
+ /**
2132
+ * The launch capability gate (BAPI-1452). Runs after readiness and executor
2133
+ * topology are resolved and BEFORE the run is created, dispatched, or given an
2134
+ * executor. Prints the compact launch summary, then refuses (exit 1) only on a
2135
+ * proven blocker; `null` means continue.
2136
+ *
2137
+ * Attended and `--into-base` launches skip it: the unattended requirements do
2138
+ * not apply there, so there is nothing for the evidence to refuse.
2139
+ */
2140
+ async function launchCapabilityGate(ctx, readiness, topology, outcome = "Nothing was created or started.") {
2141
+ const { deps, epicKey, options } = ctx;
2142
+ if (!isUnattendedLaunch(options))
2143
+ return null;
2144
+ const evidence = await collectLaunchEvidence(deps, readiness, topology, ctx.launchCredential);
2145
+ (deps.launchReadinessLog ?? deps.errorLog)(formatLaunchReadinessSummary(buildLaunchReadinessChecks(readiness, { launchEvidence: evidence }), { all: ctx.options.all, isProduction: readiness.is_production, prerequisites: readiness.prerequisites }).join("\n"));
2146
+ const classification = classifyV2Readiness(readiness, { attended: false, intoBase: false, effectivePolicy: ctx.handoff?.selection }, evidence);
2147
+ if (classification.kind !== "operator_blocked" || !LAUNCH_REFUSAL_IDS.includes(classification.id)) {
2148
+ return null;
2149
+ }
2150
+ return refuse(deps, [
2151
+ `${epicKey}: REFUSED (${classification.id}) — the launch still needs ${classification.describe}.`,
2152
+ operatorBlockedRemedy(classification),
2153
+ outcome,
2154
+ ]);
2155
+ }
2156
+ /**
2157
+ * The launch rows a preview reports, and the refusal they imply (BAPI-1452).
2158
+ * Uses the SAME evidence collection and classification as a real launch; the
2159
+ * worktree check reads the clone the read-only inspection names and never
2160
+ * prepares or writes it.
2161
+ */
2162
+ async function previewLaunchCapability(ctx, readiness, topology) {
2163
+ const { deps, options } = ctx;
2164
+ const context = { attended: options.attended === true, intoBase: options.intoBase === true };
2165
+ if (!isUnattendedLaunch(options)) {
2166
+ return { checks: buildLaunchReadinessChecks(readiness, context), refusal: null };
2167
+ }
2168
+ const evidence = await collectLaunchEvidence(deps, readiness, topology);
2169
+ const checks = buildLaunchReadinessChecks(readiness, { ...context, launchEvidence: evidence });
2170
+ const classification = classifyV2Readiness(readiness, { ...context, effectivePolicy: ctx.handoff?.selection }, evidence);
2171
+ if (classification.kind === "operator_blocked" && LAUNCH_REFUSAL_IDS.includes(classification.id)) {
2172
+ return {
2173
+ checks,
2174
+ refusal: { reasonId: classification.id, remedy: operatorBlockedRemedy(classification) },
2175
+ };
2176
+ }
2177
+ return { checks, refusal: null };
2178
+ }
1728
2179
  function durableActorAdmissionDenied(readiness) {
1729
2180
  const evidence = readiness.admission_evidence;
1730
2181
  return evidence?.schema_version === 1 &&
@@ -1743,7 +2194,7 @@ function planeEndpointForBaseUrl(deps, baseUrl) {
1743
2194
  /** What to tell the operator about a plane THIS invocation started and left up. */
1744
2195
  const LAUNCHED_PLANE_LEFT_RUNNING = [
1745
2196
  "",
1746
- "The conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.",
2197
+ "The Epic Implementer CONTROL PLANE was started by this invocation and is STILL RUNNING.",
1747
2198
  "No run was created and no executor lane was spawned. Inspect it with `plane status`,",
1748
2199
  "and wind it down with `plane down` once the item above is fixed.",
1749
2200
  ];
@@ -1782,10 +2233,430 @@ async function checkPlaneAdmission(ctx, readiness, manifest) {
1782
2233
  `${ctx.epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane's own reconciler could ` +
1783
2234
  `not be confirmed as the live transition authority (its heartbeat reads: ${state}).`,
1784
2235
  "Another reconciler may be standing in for this plane — often a reparented worker.py from an",
1785
- `earlier plane. Check \`ps aux | grep worker.py\` (${BRING_UP_RUNBOOK} §6), stop any`,
2236
+ `earlier plane. Check \`ps aux | grep worker.py\` (see ${BRING_UP_RUNBOOK}), stop any`,
1786
2237
  "survivor, and run drive-epic again.",
1787
2238
  ];
1788
2239
  }
2240
+ // ---------------------------------------------------------------------------
2241
+ // BAPI-1476 — local-plane freshness: one evidence collection, one policy
2242
+ // ---------------------------------------------------------------------------
2243
+ //
2244
+ // `plane/plane-freshness.ts` OBSERVES. This section DECIDES, and the decision
2245
+ // differs by mode: a launch that would CREATE a run refuses on a confirmed stale
2246
+ // snapshot or database head; a launch that only ATTACHES to a run that already
2247
+ // exists never stops mid-run on freshness — it warns. `--allow-stale-plane`
2248
+ // downgrades the creation refusals for one invocation and nothing else.
2249
+ /** Refusal names for a confirmed freshness problem. Unique to this check. */
2250
+ export const DRIVE_EPIC_FRESHNESS_REFUSALS = [
2251
+ /** The plane's pinned snapshot is behind `origin/main`. */
2252
+ "plane-snapshot-stale",
2253
+ /** The database revision set differs from the repository migration head. */
2254
+ "plane-database-head-mismatch",
2255
+ ];
2256
+ function createPlaneFreshnessCache() {
2257
+ return { observations: new Map() };
2258
+ }
2259
+ /**
2260
+ * Collect snapshot and database evidence ONCE per (checkout, ownership) for this
2261
+ * invocation. `null` when nothing applies: a non-plane target gets no reads at
2262
+ * all, so a production launch's output is byte-for-byte unchanged.
2263
+ */
2264
+ async function collectFreshnessObservations(ctx, own) {
2265
+ const { deps } = ctx;
2266
+ if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
2267
+ return null;
2268
+ const collect = deps.collectPlaneFreshness;
2269
+ if (collect === undefined)
2270
+ return null;
2271
+ const cache = ctx.freshness ?? createPlaneFreshnessCache();
2272
+ const key = `${own.repoRoot}\u0000${own.ownsManifest ? own.planeId ?? "" : "-"}`;
2273
+ let pending = cache.observations.get(key);
2274
+ if (pending === undefined) {
2275
+ pending = collect({
2276
+ repoRoot: own.repoRoot,
2277
+ ownsManifest: own.ownsManifest,
2278
+ ...(own.planeId !== undefined ? { expectedPlaneId: own.planeId } : {}),
2279
+ }).catch(() => null);
2280
+ cache.observations.set(key, pending);
2281
+ }
2282
+ return pending;
2283
+ }
2284
+ async function collectHostSleep(ctx, repoRoot) {
2285
+ const { deps } = ctx;
2286
+ if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
2287
+ return null;
2288
+ const inspect = deps.inspectHostSleep;
2289
+ if (inspect === undefined)
2290
+ return null;
2291
+ const cache = ctx.freshness ?? createPlaneFreshnessCache();
2292
+ cache.sleep ??= inspect(repoRoot).catch(() => null);
2293
+ return cache.sleep;
2294
+ }
2295
+ /**
2296
+ * BAPI-1496 — the bound run a stale-snapshot remedy should name, or `undefined`
2297
+ * for an unbound plane. Read only when the plane is this checkout's own, bound,
2298
+ * local, and its snapshot is confirmed stale, so the common path issues no
2299
+ * request. A failed read is an UNKNOWN status, never assumed terminal.
2300
+ */
2301
+ async function boundRunForStaleRemedy(ctx, own, reports) {
2302
+ if (!own.ownsManifest || own.epicRunId === undefined || reports === null)
2303
+ return undefined;
2304
+ // The bound remedy is shown only on a confirmed stale refusal, so only that reads.
2305
+ if (freshnessVerdictOf(reports.snapshot).verdict !== "refuse")
2306
+ return undefined;
2307
+ if (planeEndpointForBaseUrl(ctx.deps, ctx.access.baseUrl) === null)
2308
+ return undefined;
2309
+ const state = await ctx.deps.readRunState(ctx.access, own.epicRunId).catch(() => null);
2310
+ return { epicRunId: own.epicRunId, status: state?.status ?? null };
2311
+ }
2312
+ function freshnessVerdictOf(report) {
2313
+ const outcome = report.outcomes[0];
2314
+ const detail = outcome?.detail ?? "";
2315
+ switch (outcome?.status) {
2316
+ case "pass":
2317
+ return { verdict: "pass", detail };
2318
+ case "fail":
2319
+ return { verdict: "refuse", detail };
2320
+ case "skip":
2321
+ return { verdict: "skip", detail };
2322
+ default:
2323
+ return { verdict: "unverified", detail };
2324
+ }
2325
+ }
2326
+ /**
2327
+ * Project the observations into launch rows and the creation blockers.
2328
+ *
2329
+ * Row `status` stays in the existing four-state vocabulary (`fail` for a
2330
+ * confirmed refusal, `warn` for unverified evidence); the verdict word — `pass`,
2331
+ * `refuse`, `unverified` — leads the row's detail so human and JSON output carry
2332
+ * the same meaning.
2333
+ */
2334
+ function assessPlaneFreshness(reports, sleep, mode, allowStalePlane,
2335
+ /**
2336
+ * BAPI-1496 — the stale plane's bound run and its read-only server status.
2337
+ * Present only for a plane bound to a run; it selects the ownership-specific
2338
+ * remedy, which never advises `plane down` for a run that is not finished.
2339
+ * Absent keeps `PLANE_SNAPSHOT_STALE_REMEDY` byte for byte.
2340
+ */
2341
+ boundRun) {
2342
+ const rows = [];
2343
+ const blockers = [];
2344
+ let overridden = false;
2345
+ const flagNote = allowStalePlane ? "; overridden for this invocation by --allow-stale-plane" : "";
2346
+ const project = (id, label, report, refusal) => {
2347
+ const { verdict, detail } = freshnessVerdictOf(report);
2348
+ if (verdict === "pass") {
2349
+ rows.push({ id, label, status: "pass", blocking: false, detail: `pass — ${detail}` });
2350
+ }
2351
+ else if (verdict === "unverified") {
2352
+ rows.push({
2353
+ id,
2354
+ label,
2355
+ status: "warn",
2356
+ blocking: false,
2357
+ detail: `unverified — ${detail}`,
2358
+ remedy: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
2359
+ });
2360
+ }
2361
+ else if (verdict === "refuse" && mode === "attach") {
2362
+ // Warning-only: an existing run is never stopped for freshness.
2363
+ rows.push({
2364
+ id,
2365
+ label,
2366
+ status: "warn",
2367
+ blocking: false,
2368
+ detail: `refuse (warning only; attachment proceeds) — ${detail}${flagNote}`,
2369
+ remedy: refusal.remedy,
2370
+ });
2371
+ }
2372
+ else if (verdict === "refuse" && allowStalePlane) {
2373
+ overridden = true;
2374
+ rows.push({
2375
+ id,
2376
+ label,
2377
+ status: "fail",
2378
+ blocking: false,
2379
+ detail: `refuse (overridden for this invocation) — ${detail}`,
2380
+ remedy: refusal.remedy,
2381
+ });
2382
+ }
2383
+ else if (verdict === "refuse") {
2384
+ blockers.push(refusal);
2385
+ rows.push({
2386
+ id,
2387
+ label,
2388
+ status: "fail",
2389
+ blocking: true,
2390
+ detail: `refuse — ${detail}`,
2391
+ remedy: refusal.remedy,
2392
+ });
2393
+ }
2394
+ };
2395
+ if (reports !== null) {
2396
+ project(PLANE_SNAPSHOT_FRESHNESS_ID, "Plane snapshot freshness", reports.snapshot, {
2397
+ reasonId: "plane-snapshot-stale",
2398
+ remedy: boundRun !== undefined ? formatBoundPlaneStaleRemedy(boundRun) : PLANE_SNAPSHOT_STALE_REMEDY,
2399
+ });
2400
+ project(PLANE_DATABASE_HEAD_ID, "Plane database head", reports.database, {
2401
+ reasonId: "plane-database-head-mismatch",
2402
+ remedy: PLANE_DATABASE_HEAD_REMEDY,
2403
+ });
2404
+ }
2405
+ if (sleep !== null) {
2406
+ const { verdict, detail } = freshnessVerdictOf(sleep);
2407
+ if (verdict === "skip") {
2408
+ rows.push({ id: PLANE_HOST_SLEEP_ID, label: "Host sleep", status: "skip", blocking: false, detail: `not applicable — ${detail}` });
2409
+ }
2410
+ else if (verdict === "unverified") {
2411
+ rows.push({
2412
+ id: PLANE_HOST_SLEEP_ID,
2413
+ label: "Host sleep",
2414
+ status: "warn",
2415
+ blocking: false,
2416
+ detail: `unverified — ${detail}`,
2417
+ remedy: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
2418
+ });
2419
+ }
2420
+ else if (hostPowerFactFromReport(sleep).state === "present") {
2421
+ // BAPI-1506: a battery or lid advisory is a warning with its remedy, not
2422
+ // a pass. It still never blocks.
2423
+ rows.push({
2424
+ id: PLANE_HOST_SLEEP_ID,
2425
+ label: "Host sleep",
2426
+ status: "warn",
2427
+ blocking: false,
2428
+ detail: `present — ${detail}`,
2429
+ remedy: PLANE_SLEEP_REMEDY,
2430
+ });
2431
+ }
2432
+ else {
2433
+ // Readable evidence never blocks setup. "pass" means exactly that.
2434
+ rows.push({ id: PLANE_HOST_SLEEP_ID, label: "Host sleep", status: "pass", blocking: false, detail: `pass — ${detail}` });
2435
+ }
2436
+ }
2437
+ return { rows, blockers, overridden };
2438
+ }
2439
+ /** Rows worth printing on a real launch: everything except a plain pass. */
2440
+ function adverseFreshnessRows(rows) {
2441
+ return rows.filter((row) => row.status !== "pass");
2442
+ }
2443
+ /** The blockers as one named refusal; both remedies are kept, in fixed order. */
2444
+ function freshnessRefusalOf(blockers) {
2445
+ const first = blockers[0];
2446
+ if (first === undefined)
2447
+ return null;
2448
+ return { reasonId: first.reasonId, remedy: blockers.map((blocker) => blocker.remedy).join(" ") };
2449
+ }
2450
+ function renderFreshnessRows(rows, heading = 'Plane freshness:') {
2451
+ return [heading, ...rows.flatMap(row => [
2452
+ ` [${row.status === 'fail' && row.blocking ? 'Blocking' : row.status === 'warn' ? 'Warning' : 'Informational'}] ${row.label}${row.detail ? ` — ${row.detail}` : ''}`,
2453
+ ...(row.remedy ? [` → ${row.remedy}`] : []),
2454
+ ])];
2455
+ }
2456
+ /**
2457
+ * The guard for a CREATING launch, placed beside `checkPlaneAdmission` and before
2458
+ * the first setup or run-create side effect. Returns an exit code only when it
2459
+ * refuses; otherwise it prints any non-blocking finding and returns `null`.
2460
+ *
2461
+ * Fail-open on missing evidence: unreadable or absent evidence is a warning, so
2462
+ * it is never proof of staleness and never permission to skip another safeguard.
2463
+ */
2464
+ async function planeFreshnessCreateGuard(ctx, own, trailer = []) {
2465
+ try {
2466
+ const reports = await collectFreshnessObservations(ctx, own);
2467
+ if (reports === null)
2468
+ return null;
2469
+ const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
2470
+ let assessment = assessPlaneFreshness(reports, null, "create", ctx.options.allowStalePlane === true, boundRun);
2471
+ if (assessment.blockers.length > 0) {
2472
+ // setup-epic REUSES the epic's live run instead of creating one, and a reuse
2473
+ // is an attachment: it is warned about, never refused. Asked only when a
2474
+ // confirmed finding would otherwise block, so the common path reads nothing.
2475
+ const live = await ctx.deps
2476
+ .readLiveEpicRun(ctx.access, ctx.epicKey)
2477
+ .catch(() => ({ kind: "unknown" }));
2478
+ if (live.kind === "live") {
2479
+ assessment = assessPlaneFreshness(reports, null, "attach", ctx.options.allowStalePlane === true, boundRun);
2480
+ }
2481
+ }
2482
+ const refusal = freshnessRefusalOf(assessment.blockers);
2483
+ const adverse = adverseFreshnessRows(assessment.rows);
2484
+ if (refusal !== null) {
2485
+ return refuse(ctx.deps, [
2486
+ `${ctx.epicKey}: REFUSED (${refusal.reasonId}) — the local plane is not current enough to ` +
2487
+ "create a run against.",
2488
+ ...renderFreshnessRows(adverse),
2489
+ "",
2490
+ "Nothing was created.",
2491
+ ...trailer,
2492
+ ]);
2493
+ }
2494
+ if (adverse.length > 0) {
2495
+ const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
2496
+ log([
2497
+ assessment.overridden
2498
+ ? `${ctx.epicKey}: plane freshness — proceeding; a confirmed refusal was overridden for this invocation.`
2499
+ : `${ctx.epicKey}: plane freshness — proceeding; some evidence could not be verified.`,
2500
+ ...renderFreshnessRows(adverse),
2501
+ ].join("\n"));
2502
+ }
2503
+ return null;
2504
+ }
2505
+ catch {
2506
+ // An unexpected failure of an advisory read never stops a launch.
2507
+ return null;
2508
+ }
2509
+ }
2510
+ /** The warning-only counterpart for an existing run. Never refuses, never throws. */
2511
+ async function surfacePlaneFreshnessForExistingRun(ctx, own) {
2512
+ try {
2513
+ const reports = await collectFreshnessObservations(ctx, own);
2514
+ if (reports === null)
2515
+ return;
2516
+ const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
2517
+ const assessment = assessPlaneFreshness(reports, null, "attach", ctx.options.allowStalePlane === true, boundRun);
2518
+ const adverse = adverseFreshnessRows(assessment.rows);
2519
+ if (adverse.length === 0)
2520
+ return;
2521
+ const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
2522
+ log([
2523
+ `${ctx.epicKey}: plane freshness — attaching to the existing run; findings below are warnings only.`,
2524
+ ...renderFreshnessRows(adverse),
2525
+ ].join("\n"));
2526
+ }
2527
+ catch {
2528
+ // Advisory only.
2529
+ }
2530
+ }
2531
+ // ---------------------------------------------------------------------------
2532
+ // BAPI-1496 — worker-database advisories on a LOCAL launch
2533
+ // ---------------------------------------------------------------------------
2534
+ /**
2535
+ * Run the worker-database inspection for a local-plane target, once.
2536
+ *
2537
+ * `null` when nothing applies: a target that is not this machine's plane never
2538
+ * runs the inspector, so a remote launch's requests and output are unchanged.
2539
+ * An inspector that throws or times out prints one fixed line naming only the
2540
+ * closed classification, then yields the doctor's all-unknown section — whose
2541
+ * `worker-db-unverifiable` row is a warning, never a refusal.
2542
+ */
2543
+ async function collectWorkerDatabaseAdvisory(deps, baseUrl, cache) {
2544
+ if (baseUrl === null || planeEndpointForBaseUrl(deps, baseUrl) === null)
2545
+ return null;
2546
+ const inspect = deps.inspectWorkerDatabase;
2547
+ if (inspect === undefined)
2548
+ return null;
2549
+ const run = async () => {
2550
+ let failed = false;
2551
+ const section = await collectWorkerDatabaseDoctorSection(inspect, WORKER_DATABASE_DOCTOR_TIMEOUT_MS, (failure) => {
2552
+ failed = true;
2553
+ deps.errorLog(`worker-db inspection failed: ${failure}`);
2554
+ }).catch(() => null);
2555
+ // A failed or timed-out inspection is ONE fact — the evidence is unverifiable —
2556
+ // so a launch shows only the `worker-db-unverifiable` warning, not six unknown
2557
+ // rows. The doctor's own all-unknown section is left as it is.
2558
+ if (section !== null && failed) {
2559
+ return { ...section, rows: section.rows.filter((row) => row.id === "worker-db-unverifiable") };
2560
+ }
2561
+ return section;
2562
+ };
2563
+ if (cache === undefined)
2564
+ return run();
2565
+ cache.workerDatabase ??= run();
2566
+ return cache.workerDatabase;
2567
+ }
2568
+ /** The advisory rows for a local launch: non-healthy `worker-db-*` warnings only. */
2569
+ async function workerDatabaseAdvisoryRows(deps, baseUrl, cache) {
2570
+ const section = await collectWorkerDatabaseAdvisory(deps, baseUrl, cache);
2571
+ return section === null ? [] : previewLaunchRows(workerDatabaseReadinessChecks(section));
2572
+ }
2573
+ /**
2574
+ * Print the worker-database warnings on a real local launch, beside the other
2575
+ * launch readiness output. Called after the creating launch's freshness guard
2576
+ * on every local creating path, attended and `--into-base` included. Advisory by
2577
+ * construction: it never refuses, never changes a decision or an exit code, and
2578
+ * an unexpected failure prints nothing.
2579
+ */
2580
+ async function surfaceWorkerDatabaseAdvisory(ctx) {
2581
+ try {
2582
+ const rows = await workerDatabaseAdvisoryRows(ctx.deps, ctx.access.baseUrl, ctx.freshness);
2583
+ if (rows.length === 0)
2584
+ return;
2585
+ const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
2586
+ log([
2587
+ `${ctx.epicKey}: worker databases — warnings only; they never block a launch.`,
2588
+ ...renderFreshnessRows(rows).map((line, index) => (index === 0 ? "Worker databases (advisory):" : line)),
2589
+ ].join("\n"));
2590
+ }
2591
+ catch {
2592
+ // Advisory only.
2593
+ }
2594
+ }
2595
+ /**
2596
+ * Append the worker-database warnings to a LOCAL-plane preview's
2597
+ * `launch_checks`, modeled on {@link appendExecutorBuildFreshnessWarning}.
2598
+ * Gated on the preview's own `target.kind`, so a remote preview never runs the
2599
+ * inspector and its JSON is unchanged. Never changes the decision, a refusal,
2600
+ * readiness, or the exit code.
2601
+ */
2602
+ async function appendWorkerDatabaseAdvisoryRows(deps, preview) {
2603
+ if (preview.target.kind !== "local_plane")
2604
+ return null;
2605
+ try {
2606
+ // BAPI-1506: the section is returned so the launch-need step reuses this one
2607
+ // inspection instead of running a second.
2608
+ const section = await collectWorkerDatabaseAdvisory(deps, preview.target.base_url);
2609
+ const rows = section === null ? [] : previewLaunchRows(workerDatabaseReadinessChecks(section));
2610
+ if (rows.length > 0)
2611
+ preview.launch_checks = [...(preview.launch_checks ?? []), ...rows];
2612
+ return section;
2613
+ }
2614
+ catch {
2615
+ // Leave the preview as it was.
2616
+ return null;
2617
+ }
2618
+ }
2619
+ // ---------------------------------------------------------------------------
2620
+ // BAPI-1496 — terminal-run plane cleanup before a LOCAL creating launch
2621
+ // ---------------------------------------------------------------------------
2622
+ /**
2623
+ * Tear down this checkout's plane when it serves a finished run, before the
2624
+ * launch can attach to it or its preflight can refuse on it.
2625
+ *
2626
+ * `null` when nothing applies: a target that is not this machine's plane never
2627
+ * reaches the cleanup, so a remote launch makes no new call and sends no
2628
+ * signal. Eligibility, ownership re-validation, and the guarded teardown all
2629
+ * live in `plane/terminal-run-cleanup.ts`; this only routes. An incomplete or
2630
+ * aborted cleanup prints its one line and the launch continues into ordinary
2631
+ * preflight, whose `existing-plane` refusal still applies. A completed cleanup
2632
+ * drops every cached freshness observation, because they described the plane
2633
+ * that is now gone.
2634
+ */
2635
+ async function cleanUpTerminalRunPlane(ctx, repoRoot) {
2636
+ const { deps } = ctx;
2637
+ const cleanup = deps.cleanupTerminalRunPlane;
2638
+ if (cleanup === undefined)
2639
+ return null;
2640
+ const endpoint = planeEndpointForBaseUrl(deps, ctx.access.baseUrl);
2641
+ if (endpoint === null)
2642
+ return null;
2643
+ let result;
2644
+ try {
2645
+ result = await cleanup({ repoRoot, endpoint });
2646
+ }
2647
+ catch {
2648
+ deps.errorLog("terminal-run plane cleanup could not run; inspect `plane status`.");
2649
+ return null;
2650
+ }
2651
+ const line = terminalRunCleanupStderrLine(result);
2652
+ if (line !== null)
2653
+ deps.errorLog(line);
2654
+ if (result.kind === "completed" && ctx.freshness !== undefined) {
2655
+ ctx.freshness.observations.clear();
2656
+ ctx.freshness.sleep = undefined;
2657
+ }
2658
+ return result;
2659
+ }
1789
2660
  /**
1790
2661
  * COLD START: nothing is listening on the plane endpoint, so bring the control
1791
2662
  * plane up and compose the run against it.
@@ -1816,13 +2687,15 @@ async function coldStartPlane(ctx) {
1816
2687
  // before anything is started, exactly as every other plan-file route does.
1817
2688
  if (ctx.options.planFile)
1818
2689
  await surfaceContainmentAdvisory(deps, ctx.options.planFile);
1819
- deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the conductor plane.`);
2690
+ deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the local plane.`);
1820
2691
  const repoRoot = deps.cwd();
2692
+ // BAPI-1496: a plane bound to a finished run is torn down before preflight.
2693
+ await cleanUpTerminalRunPlane(ctx, repoRoot);
1821
2694
  const preflight = await deps.runPlanePreflight(repoRoot);
1822
2695
  const precondition = firstPreconditionRefusal(preflight);
1823
2696
  if (precondition) {
1824
2697
  return refuse(deps, [
1825
- `${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
2698
+ `${epicKey}: cannot start the Epic Implementer runtime — ${precondition.check} failed.`,
1826
2699
  formatPlaneDiagnostic(precondition),
1827
2700
  "",
1828
2701
  "Nothing was started. Fix the item above and run drive-epic again.",
@@ -1841,8 +2714,11 @@ async function coldStartPlane(ctx) {
1841
2714
  readiness = await deps.readReadiness(ctx.access);
1842
2715
  }
1843
2716
  catch (err) {
2717
+ if (isSchemaIncompatibleError(err)) {
2718
+ return refuse(deps, [...formatSchemaIncompatibleRefusal(epicKey, err), ...LAUNCHED_PLANE_LEFT_RUNNING]);
2719
+ }
1844
2720
  return refuse(deps, [
1845
- `${epicKey}: the plane came up, but its conductor readiness could not be read: ` +
2721
+ `${epicKey}: the plane came up, but its Epic Implementer readiness could not be read: ` +
1846
2722
  safeDiagnosticMessage(err, "readiness request failed"),
1847
2723
  ...LAUNCHED_PLANE_LEFT_RUNNING,
1848
2724
  ]);
@@ -1862,7 +2738,7 @@ async function coldStartPlane(ctx) {
1862
2738
  });
1863
2739
  if (classification.kind === "operator_blocked") {
1864
2740
  return refuse(deps, [
1865
- `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
2741
+ `${epicKey}: the engine path still needs ${classification.describe}.`,
1866
2742
  operatorBlockedRemedy(classification),
1867
2743
  ...LAUNCHED_PLANE_LEFT_RUNNING,
1868
2744
  ]);
@@ -1879,12 +2755,17 @@ async function coldStartPlane(ctx) {
1879
2755
  if (admission !== null) {
1880
2756
  return refuse(deps, [...admission, ...LAUNCHED_PLANE_LEFT_RUNNING]);
1881
2757
  }
2758
+ // BAPI-1476: before the first setup/run-create side effect.
2759
+ const staleCold = await planeFreshnessCreateGuard(ctx, { repoRoot, ownsManifest: true, planeId: controlPlane.planeId }, LAUNCHED_PLANE_LEFT_RUNNING);
2760
+ if (staleCold !== null)
2761
+ return staleCold;
2762
+ await surfaceWorkerDatabaseAdvisory(ctx);
1882
2763
  return createRunAndRequestLanes(ctx, {
1883
2764
  origin: "launched",
1884
2765
  repoRoot,
1885
2766
  planeId: controlPlane.planeId,
1886
2767
  awaitPlaneReady: controlPlane.awaitPlaneReady,
1887
- });
2768
+ }, readiness);
1888
2769
  }
1889
2770
  /**
1890
2771
  * ATTACH: the plane endpoint already serves this repository's live plane.
@@ -1945,6 +2826,16 @@ async function attachToPlane(ctx, manifest, readiness) {
1945
2826
  if (admission !== null) {
1946
2827
  return refuse(deps, [...admission, "", "Nothing was started."]);
1947
2828
  }
2829
+ // BAPI-1476: both branches below create a run, so the guard precedes both.
2830
+ const staleAttach = await planeFreshnessCreateGuard(attachCtx, {
2831
+ repoRoot: manifest.repoRoot,
2832
+ ownsManifest: true,
2833
+ planeId: manifest.planeId,
2834
+ ...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
2835
+ });
2836
+ if (staleAttach !== null)
2837
+ return staleAttach;
2838
+ await surfaceWorkerDatabaseAdvisory(attachCtx);
1948
2839
  if (dedicatedExecutorReason !== null) {
1949
2840
  deps.log(`${epicKey}: attaching to this repository's running plane (${dedicatedExecutorReason}); ` +
1950
2841
  "its reconciler will drive this run too, and a dedicated executor will be started for it.");
@@ -1956,7 +2847,7 @@ async function attachToPlane(ctx, manifest, readiness) {
1956
2847
  repoRoot: manifest.repoRoot,
1957
2848
  planeId: manifest.planeId,
1958
2849
  ...(manifest.epicRunId !== undefined ? { boundRunId: manifest.epicRunId } : {}),
1959
- });
2850
+ }, readiness);
1960
2851
  }
1961
2852
  /**
1962
2853
  * The shared tail: create/approve/cut through `setup-epic`, then request lanes
@@ -1967,9 +2858,16 @@ async function attachToPlane(ctx, manifest, readiness) {
1967
2858
  * `executorClaimScopeArgs` as `--epic-run-id`, and `--repo-wide` is not
1968
2859
  * constructible here.
1969
2860
  */
1970
- async function createRunAndRequestLanes(ctx, plane) {
2861
+ async function createRunAndRequestLanes(ctx, plane, readiness) {
1971
2862
  const { deps, epicKey } = ctx;
1972
2863
  const launched = plane.origin === "launched";
2864
+ // BAPI-1452: a plane lane owns this run's executor, so the launch evidence
2865
+ // can only warn here; printed before the run exists.
2866
+ if (readiness !== undefined) {
2867
+ const gate = await launchCapabilityGate(ctx, readiness, { kind: "plane_lane" });
2868
+ if (gate !== null)
2869
+ return gate;
2870
+ }
1973
2871
  // ---- PHASE TWO, part one: create the run ------------------------------
1974
2872
  const setup = await runSelectedSetupEpic(ctx);
1975
2873
  if (setup.exitCode !== 0) {
@@ -1981,7 +2879,7 @@ async function createRunAndRequestLanes(ctx, plane) {
1981
2879
  deps.errorLog((launched
1982
2880
  ? [
1983
2881
  "",
1984
- `${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
2882
+ `${epicKey}: the Epic Implementer CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
1985
2883
  "No executor lane was spawned, so nothing is claiming jobs.",
1986
2884
  "It was not wound down automatically, because doing so would stop a run that may",
1987
2885
  "already have been created and approved. Inspect it with `plane status`, and wind it",
@@ -2158,7 +3056,7 @@ function remoteRuntimeRefusal(epicKey, access, readiness) {
2158
3056
  const lines = [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`];
2159
3057
  if (bringUp === null) {
2160
3058
  lines.push("This Bridge API version cannot report deployment liveness, so a launch against it cannot " +
2161
- "be confirmed. Upgrade the Bridge API deploy.");
3059
+ "be confirmed. Try again later; if it keeps happening, email info@bridgegpt.ai.");
2162
3060
  }
2163
3061
  lines.push("", "No run was created.", REMOTE_NO_PLANE_LINE);
2164
3062
  return { reasonId, remedy, lines };
@@ -2171,7 +3069,7 @@ function remoteRuntimeRefusal(epicKey, access, readiness) {
2171
3069
  function durableCoverageRefusal(epicKey, access, profile, coverage, outcome) {
2172
3070
  const remedy = [
2173
3071
  "Start the durable host's executor with --repo-wide",
2174
- " (`install conductor --execution-mode durable --executor-id <id> --repo-wide`),",
3072
+ " (`install conductor --execution-mode durable --executor-id <id> --repo-wide`; see `drive-epic --help-advanced`),",
2175
3073
  "or launch without the durable profile.",
2176
3074
  ].join("\n");
2177
3075
  return {
@@ -2272,6 +3170,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
2272
3170
  if (coverage !== true) {
2273
3171
  return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
2274
3172
  }
3173
+ // BAPI-1452: the durable host owns the executor clone and credential, so
3174
+ // this can only warn — but it says so before the run exists.
3175
+ const gate = await launchCapabilityGate(ctx, readiness, { kind: "durable_profile" }, "No run was created and no local executor was started.");
3176
+ if (gate !== null)
3177
+ return gate;
2275
3178
  const setup = await runSelectedSetupEpic(ctx);
2276
3179
  if (setup.exitCode === 0) {
2277
3180
  deps.log([
@@ -2287,6 +3190,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
2287
3190
  if (!prepared.ok) {
2288
3191
  return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
2289
3192
  }
3193
+ // BAPI-1452: the launch capability gate checks the SAME clone the executor
3194
+ // will own (`prepared.repoRoot`, its cwd) — not a second preparation.
3195
+ const gate = await launchCapabilityGate(ctx, readiness, { kind: "local", repoRoot: prepared.prepared.repoRoot }, "No run was created.");
3196
+ if (gate !== null)
3197
+ return gate;
2290
3198
  const setup = await runSelectedSetupEpic(ctx);
2291
3199
  if (setup.exitCode !== 0) {
2292
3200
  // setup-epic already said why. Nothing was started for a run that may not exist.
@@ -2303,13 +3211,16 @@ async function createRunWithRunExecutor(ctx, readiness) {
2303
3211
  "server-side before retrying; retrying blindly could create a second run.",
2304
3212
  ]);
2305
3213
  }
2306
- // POST-CREATE: start or reuse exactly one executor scoped to this run.
3214
+ // POST-CREATE: start or reuse exactly one executor scoped to this run. It
3215
+ // receives the merge credential this launch resolved (BAPI-1461), so a token
3216
+ // stored once works without exporting it or restarting the editor.
2307
3217
  const ensured = await deps.ensureRunExecutor({
2308
3218
  prepared: prepared.prepared,
2309
3219
  repoName: access.repoName,
2310
3220
  epicRunId: setup.epicRunId,
2311
3221
  access,
2312
3222
  profile,
3223
+ mergeCredential: await launchCredentialForExecutor(ctx, readiness),
2313
3224
  });
2314
3225
  return reportExecutorOutcome(ctx, ensured, setup.epicRunId, "created");
2315
3226
  }
@@ -2400,7 +3311,7 @@ async function planeServesLiveRun(ctx, live) {
2400
3311
  * - this checkout's plane bound to THIS run: its lanes serve it, start nothing;
2401
3312
  * - otherwise: prepare, then ensure a run-scoped executor for the LIVE run's id.
2402
3313
  */
2403
- async function attachToLiveRun(ctx, readiness, live) {
3314
+ async function attachToLiveRun(ctx, readiness, live, unusedDiscoveredPlan) {
2404
3315
  const { deps, epicKey, access, profile } = ctx;
2405
3316
  const classification = classifyV2Readiness(readiness, {
2406
3317
  attended: ctx.options.attended === true,
@@ -2409,7 +3320,7 @@ async function attachToLiveRun(ctx, readiness, live) {
2409
3320
  });
2410
3321
  if (classification.kind === "operator_blocked") {
2411
3322
  return refuse(deps, [
2412
- `${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
3323
+ `${epicKey}: the engine path still needs ${classification.describe}.`,
2413
3324
  "",
2414
3325
  "This is not something starting the runtime can fix, so nothing was started.",
2415
3326
  operatorBlockedRemedy(classification),
@@ -2428,9 +3339,22 @@ async function attachToLiveRun(ctx, readiness, live) {
2428
3339
  if (admission !== null)
2429
3340
  return refuse(deps, admission);
2430
3341
  }
3342
+ // BAPI-1476: warning-only. An existing run is never stopped for freshness,
3343
+ // and the plane is neither repaired nor restarted here.
3344
+ await surfacePlaneFreshnessForExistingRun(ctx, manifest !== null
3345
+ ? {
3346
+ repoRoot: manifest.repoRoot,
3347
+ ownsManifest: true,
3348
+ planeId: manifest.planeId,
3349
+ ...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
3350
+ }
3351
+ : { repoRoot: deps.cwd(), ownsManifest: false });
2431
3352
  }
2432
- deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
2433
- "found, so nothing will be created; making sure it has an executor.");
3353
+ deps.log(unusedDiscoveredPlan === undefined
3354
+ ? `${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
3355
+ "found, so nothing will be created; making sure it has an executor."
3356
+ : `${epicKey}: run ${live.epicRunId} is already live (${live.status}); the plan found by epic key ` +
3357
+ `(${unusedDiscoveredPlan}) is not used, so nothing will be created; making sure it has an executor.`);
2434
3358
  if (live.effectivePolicy) {
2435
3359
  deps.log(`Effective policy: stored; serial execution; caps ${live.effectivePolicy.caps ? JSON.stringify(live.effectivePolicy.caps) : "unavailable"}; ` +
2436
3360
  `timeouts ${live.effectivePolicy.job_timeouts ? JSON.stringify(live.effectivePolicy.job_timeouts) : "unavailable"}; ` +
@@ -2472,6 +3396,7 @@ async function attachToLiveRun(ctx, readiness, live) {
2472
3396
  epicRunId: live.epicRunId,
2473
3397
  access,
2474
3398
  profile,
3399
+ mergeCredential: await launchCredentialForExecutor(ctx, readiness),
2475
3400
  });
2476
3401
  return reportExecutorOutcome(ctx, ensured, live.epicRunId, "attached");
2477
3402
  }
@@ -2486,8 +3411,8 @@ function localReconcilerRefusal(epicKey, readiness) {
2486
3411
  const reasonId = reason ?? "local-reconciler-not-live";
2487
3412
  const remedy = reason !== null
2488
3413
  ? BRING_UP_REFUSAL_MESSAGES[reason]
2489
- : "the local conductor plane reports no live reconciler, so nothing would drive this run. " +
2490
- "Start it with `plane up`, or pass --plan-file so drive-epic can bring the runtime up.";
3414
+ : "the local plane reports no live reconciler, so nothing would drive this run. " +
3415
+ "Start it as `drive-epic --help-advanced` describes, or pass --plan-file so drive-epic can bring the runtime up.";
2491
3416
  return {
2492
3417
  reasonId,
2493
3418
  remedy,
@@ -2537,9 +3462,20 @@ export const DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS = {
2537
3462
  "credentials-unavailable": "access-credentials-unavailable",
2538
3463
  "credential-unbound": "access-credential-unbound",
2539
3464
  };
3465
+ /** BAPI-1452 — project launch rows into the stable preview shape. */
3466
+ function previewLaunchRows(checks) {
3467
+ return checks.map((check) => ({
3468
+ id: check.id,
3469
+ label: check.label,
3470
+ status: check.status,
3471
+ blocking: check.blocking,
3472
+ ...(check.detail ? { detail: check.detail } : {}),
3473
+ ...(check.remediation ? { remedy: check.remediation } : {}),
3474
+ }));
3475
+ }
2540
3476
  /** The readiness reason a preview reports for a generic not-ready engine. */
2541
3477
  const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
2542
- const ENGINE_NOT_READY_REMEDY = `Resolve conductor readiness (\`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\`, then \`${PRINTED_DOCTOR_COMMAND}\`) and run drive-epic again. ${PRINTED_CHECK_NAME_INSTRUCTION}`;
3478
+ const ENGINE_NOT_READY_REMEDY = `Resolve Epic Implementer readiness (\`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\`, then \`${PRINTED_DOCTOR_COMMAND}\`) and run drive-epic again. ${PRINTED_CHECK_NAME_INSTRUCTION} See ${CONSUMER_GUIDE_POINTER}.`;
2543
3479
  /**
2544
3480
  * Read exactly one well-shaped setup-epic dry-run JSON document out of captured
2545
3481
  * `log` output. Anything else — none, several, or one with the wrong shape — is
@@ -2608,8 +3544,19 @@ function readSetupPreviewPayload(stdout) {
2608
3544
  return {
2609
3545
  existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
2610
3546
  featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
3547
+ // An older bootstrap omits it; anything not a valid branch name is dropped.
3548
+ scopeBaseBranch: typeof doc.scope_base_branch === "string" && validateBranchName(doc.scope_base_branch) === null
3549
+ ? doc.scope_base_branch
3550
+ : null,
2611
3551
  serverValidated: doc.server_validated,
2612
3552
  effectivePolicy,
3553
+ ticketReview: typeof doc.ticket_review === 'string' && /^(on|off \(legacy choice not recorded\)|off requested \(admin key required; choice recorded at create\)|off \(set by actor_[a-z0-9]{4,32} on \d{4}-\d{2}-\d{2}\))$/.test(doc.ticket_review) ? doc.ticket_review : 'on',
3554
+ launchReadiness: (() => { try {
3555
+ return doc.launch_readiness ? parseConductorReadinessResponse(doc.launch_readiness) : null;
3556
+ }
3557
+ catch {
3558
+ return null;
3559
+ } })(),
2613
3560
  };
2614
3561
  }
2615
3562
  /** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
@@ -2639,12 +3586,15 @@ async function runDriveEpicPreview(deps, options) {
2639
3586
  readiness: { kind: "unknown" },
2640
3587
  live_run: null,
2641
3588
  plan: { status: "not_found" },
3589
+ already_done_children: { status: "not_checked", reason: "no_plan", children: [] },
2642
3590
  summary: {
2643
3591
  child_count: null,
3592
+ children_to_run: null,
2644
3593
  branch: null,
2645
3594
  posture,
2646
3595
  auto_merge_scope: null,
2647
3596
  escalation_channel: null,
3597
+ ticket_review: "on",
2648
3598
  server_validation: "not_run",
2649
3599
  },
2650
3600
  decision: "refuse",
@@ -2667,8 +3617,11 @@ async function runDriveEpicPreview(deps, options) {
2667
3617
  preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
2668
3618
  preview.decision = "refuse";
2669
3619
  };
3620
+ // The readiness read previewDecision observed, for the reachability row. It stays
3621
+ // `null` when no read succeeded, which the row reports as unconfirmed.
3622
+ const observed = { readiness: null };
2670
3623
  try {
2671
- await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel);
3624
+ await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel, observed);
2672
3625
  }
2673
3626
  catch {
2674
3627
  // Sanitized on purpose: nothing from the thrown value reaches the document.
@@ -2680,6 +3633,10 @@ async function runDriveEpicPreview(deps, options) {
2680
3633
  });
2681
3634
  }
2682
3635
  ensureRefusalExplained(preview);
3636
+ const workerDatabase = await appendWorkerDatabaseAdvisoryRows(deps, preview);
3637
+ appendEscalationReachabilityRow(preview, observed.readiness);
3638
+ await appendExecutorBuildFreshnessWarning(deps, preview);
3639
+ await appendLaunchNeedRows(deps, preview, observed, workerDatabase);
2683
3640
  const emitted = await emitPreview(deps, options, preview);
2684
3641
  // BAPI-1397 (F3): a `--json` document that could not reach stdout whole is a
2685
3642
  // failed preview, never a silently truncated one.
@@ -2687,9 +3644,159 @@ async function runDriveEpicPreview(deps, options) {
2687
3644
  return 1;
2688
3645
  return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
2689
3646
  }
3647
+ /**
3648
+ * Give every `create` or `attach` preview exactly one non-blocking
3649
+ * `escalation-reachability` row (BAPI-1490).
3650
+ *
3651
+ * Built from the readiness the preview already read and the channel the preview
3652
+ * already resolved; it adds no request and never changes the decision, a refusal,
3653
+ * a reason, or the exit code. A readiness that was not read (cold start, remote
3654
+ * read failure) is passed as unavailable, so the row warns rather than vanishing.
3655
+ * A refused or stopped preview describes no launch, so it gets none.
3656
+ */
3657
+ function appendEscalationReachabilityRow(preview, readiness) {
3658
+ if (preview.decision !== "create" && preview.decision !== "attach")
3659
+ return;
3660
+ const [row] = previewLaunchRows([
3661
+ buildEscalationReachabilityCheck(readiness, preview.summary.escalation_channel),
3662
+ ]);
3663
+ preview.launch_checks = [
3664
+ ...(preview.launch_checks ?? []).filter((existing) => existing.id !== ESCALATION_REACHABILITY_CHECK_ID),
3665
+ row,
3666
+ ];
3667
+ }
3668
+ /**
3669
+ * Append a non-blocking `executor-build-freshness` warning to the preview when a
3670
+ * local CLI override (`BAPI_MCP_CLI`) is set and the executor build is stale or
3671
+ * unreadable. Fail-open and read-only: it never changes the decision, a refusal,
3672
+ * readiness, or the exit code. A passing check, an unset or blank override, or a
3673
+ * missing dependency leaves `launch_checks` untouched. A thrown check prints one
3674
+ * fixed line and nothing from the exception.
3675
+ */
3676
+ async function appendExecutorBuildFreshnessWarning(deps, preview) {
3677
+ const override = process.env[BRIDGE_LAUNCHER_ENV];
3678
+ if (override === undefined || override.trim() === "")
3679
+ return;
3680
+ if (deps.checkExecutorBuildFreshness === undefined)
3681
+ return;
3682
+ try {
3683
+ const report = await deps.checkExecutorBuildFreshness(deps.cwd());
3684
+ if (report.diagnostic === null)
3685
+ return;
3686
+ const outcome = report.outcomes.find((entry) => entry.status === "warn" || entry.status === "fail") ?? report.outcomes[0];
3687
+ const row = {
3688
+ id: "executor-build-freshness",
3689
+ label: outcome?.label ?? "Executor build freshness",
3690
+ status: "warn",
3691
+ blocking: false,
3692
+ ...(outcome?.detail ? { detail: outcome.detail } : {}),
3693
+ remedy: PLANE_BUILD_FIX_REMEDIATION,
3694
+ };
3695
+ preview.launch_checks = [...(preview.launch_checks ?? []), row];
3696
+ }
3697
+ catch {
3698
+ deps.errorLog("Executor build freshness check could not run.");
3699
+ }
3700
+ }
3701
+ /**
3702
+ * The base branch setup-epic's dry run reported, per preview. Kept off the
3703
+ * preview document so its JSON shape is unchanged.
3704
+ */
3705
+ const previewScopeBaseBranch = new WeakMap();
3706
+ /** Run one probe; a missing boundary or any rejection becomes the fallback fact. */
3707
+ async function guardedProbe(run, fallback) {
3708
+ if (run === undefined)
3709
+ return fallback;
3710
+ try {
3711
+ return await run();
3712
+ }
3713
+ catch {
3714
+ return fallback;
3715
+ }
3716
+ }
3717
+ /** Power evidence for the host the run's executor will run on. */
3718
+ async function collectHostPowerFact(deps, topology) {
3719
+ // `local` and `plane_lane` executors run on this host (a plane lane is chosen
3720
+ // only for this machine's own plane). Anything else runs elsewhere.
3721
+ if (topology === undefined)
3722
+ return { state: "unverifiable", reason: "unverified" };
3723
+ if (topology.kind === "durable_profile")
3724
+ return { state: "unverifiable", reason: "remote_executor" };
3725
+ const inspect = deps.inspectHostSleep;
3726
+ if (inspect === undefined)
3727
+ return { state: "unverifiable", reason: "probe_failed" };
3728
+ const repoRoot = topology.kind === "local" && topology.repoRoot !== null ? topology.repoRoot : deps.cwd();
3729
+ const report = await inspect(repoRoot).catch(() => null);
3730
+ return hostPowerFactFromReport(report);
3731
+ }
3732
+ /**
3733
+ * Append the five launch-need rows to a `create` preview (BAPI-1506): worker DB
3734
+ * role, self-hosted runner, host power (`host-sleep`), usage reset after the
3735
+ * run, and the information-only epic branch behind base.
3736
+ *
3737
+ * Every probe runs concurrently and is guarded on its own, so a missing
3738
+ * boundary, a rejection or a timeout is an `unverifiable` warning row. Rows
3739
+ * are `blocking: false` and NEVER change the decision, a refusal, readiness or
3740
+ * the exit code. An `attach`, refused or stopped preview gets none of them.
3741
+ * The merged `worker-db-role` warning row is decorated in place rather than
3742
+ * duplicated.
3743
+ */
3744
+ async function appendLaunchNeedRows(deps, preview, observed, workerDatabase) {
3745
+ if (preview.decision !== "create")
3746
+ return;
3747
+ try {
3748
+ const repoRoot = deps.cwd();
3749
+ const local = preview.target.kind === "local_plane";
3750
+ const readiness = observed.readiness;
3751
+ const access = observed.access;
3752
+ const nowMs = (deps.now ?? Date.now)();
3753
+ const [runner, power, usage, branch] = await Promise.all([
3754
+ guardedProbe(deps.inspectRequiredRunners === undefined
3755
+ ? undefined
3756
+ : () => deps.inspectRequiredRunners({
3757
+ repoRoot,
3758
+ providerRepository: readiness?.provider_repository,
3759
+ gateProfile: readiness?.gate_profile,
3760
+ }), { state: "unverifiable", reason: "probe_failed" }),
3761
+ guardedProbe(() => collectHostPowerFact(deps, observed.launchTopology), { state: "unverifiable", reason: "probe_failed" }),
3762
+ guardedProbe(deps.fetchUsageAdvisory === undefined || access === undefined
3763
+ ? undefined
3764
+ : () => deps.fetchUsageAdvisory(access), null),
3765
+ guardedProbe(deps.inspectEpicBranchBehind === undefined
3766
+ ? undefined
3767
+ : () => deps.inspectEpicBranchBehind({
3768
+ repoRoot,
3769
+ branch: preview.summary.branch,
3770
+ baseBranch: previewScopeBaseBranch.get(preview) ?? null,
3771
+ }), preview.summary.branch === null
3772
+ ? { state: "not_applicable", reason: "no_epic_branch" }
3773
+ : { state: "unverifiable", reason: "git_failed" }),
3774
+ ]);
3775
+ const workerRow = buildWorkerDbRoleLaunchRow(workerDatabase, local);
3776
+ const rows = [
3777
+ buildSelfHostedRunnerLaunchRow(runner),
3778
+ buildHostPowerLaunchRow(power),
3779
+ buildUsageResetAfterRunLaunchRow({
3780
+ advisory: usage ?? REPOSITORY_USAGE_ADVISORY_UNAVAILABLE,
3781
+ childCount: preview.summary.child_count,
3782
+ nowMs,
3783
+ }),
3784
+ buildEpicBranchBehindBaseLaunchRow(branch),
3785
+ ];
3786
+ const existing = preview.launch_checks ?? [];
3787
+ const roleIndex = existing.findIndex((row) => row.id === workerRow.id);
3788
+ const merged = roleIndex >= 0
3789
+ ? existing.map((row, index) => (index === roleIndex ? workerRow : row))
3790
+ : [...existing, workerRow];
3791
+ preview.launch_checks = [...merged, ...rows];
3792
+ }
3793
+ catch {
3794
+ // Advisory only: leave the preview as it was.
3795
+ }
3796
+ }
2690
3797
  /** The fixed explanation used when a refusal reached the end with none of its own. */
2691
3798
  export const PREVIEW_REFUSAL_FALLBACK_REASON_ID = "preview-refused";
2692
- const PREVIEW_REFUSAL_FALLBACK_REMEDY = "The preview refused this launch without naming a specific cause. Resolve conductor readiness and preview again; nothing was created or started.";
3799
+ const PREVIEW_REFUSAL_FALLBACK_REMEDY = `The preview refused this launch without naming a specific cause. Resolve Epic Implementer readiness and preview again; nothing was created or started. See ${CONSUMER_GUIDE_POINTER}.`;
2693
3800
  function isNamedRefusal(value) {
2694
3801
  if (value === null || typeof value !== "object")
2695
3802
  return false;
@@ -2717,7 +3824,7 @@ export function ensureRefusalExplained(preview) {
2717
3824
  : { reason_id: PREVIEW_REFUSAL_FALLBACK_REASON_ID, remedy: PREVIEW_REFUSAL_FALLBACK_REMEDY };
2718
3825
  }
2719
3826
  /** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
2720
- async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
3827
+ async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel, observed = { readiness: null }) {
2721
3828
  const { epicKey } = options;
2722
3829
  const unknownReadiness = () => refuseWithReadiness("unknown", {
2723
3830
  reasonId: READINESS_UNKNOWN_REASON_ID,
@@ -2753,11 +3860,22 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2753
3860
  ? { source: accessResult.targetSource }
2754
3861
  : {}),
2755
3862
  };
2756
- const ctx = { deps, epicKey, options, access, profile, targetSource: accessResult.targetSource ?? "default" };
3863
+ const ctx = {
3864
+ deps,
3865
+ epicKey,
3866
+ options,
3867
+ access,
3868
+ profile,
3869
+ targetSource: accessResult.targetSource ?? "default",
3870
+ freshness: createPlaneFreshnessCache(),
3871
+ launchCredential: {},
3872
+ };
2757
3873
  // Discover before readiness so a selected local plane can preview its guarded
2758
3874
  // cold start. Keep the candidate out of ordinary failure documents.
2759
3875
  let planPath = options.planFile;
2760
3876
  let plan = { status: "not_found" };
3877
+ // The full discovery result, kept for the guarded plane-down stop below.
3878
+ let discovery = null;
2761
3879
  let docsRootUnresolved = false;
2762
3880
  if (planPath !== undefined) {
2763
3881
  plan = { status: "supplied", path: planPath };
@@ -2769,6 +3887,7 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2769
3887
  }
2770
3888
  else {
2771
3889
  const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
3890
+ discovery = discovered;
2772
3891
  plan = {
2773
3892
  status: discovered.status === "found" ? "found" : discovered.status === "plan_ambiguous" ? "ambiguous" : "not_found",
2774
3893
  ...(discovered.status === "found" ? { path: discovered.path } : {}),
@@ -2782,12 +3901,21 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2782
3901
  planPath = discovered.path;
2783
3902
  }
2784
3903
  }
3904
+ // Every later exit, refusal or not, reports the plan state as discovered.
3905
+ preview.plan = plan;
2785
3906
  // --- Readiness. -----------------------------------------------------------
2786
3907
  let readiness;
2787
3908
  try {
2788
3909
  readiness = await deps.readReadiness(access);
2789
3910
  }
2790
3911
  catch (err) {
3912
+ if (isSchemaIncompatibleError(err)) {
3913
+ return refuseTopLevel({
3914
+ reasonId: "schema-incompatible",
3915
+ remedy: "Local schema incompatible: launch-critical schema objects are missing. " +
3916
+ `${SCHEMA_INCOMPATIBLE_REPAIR_ROUTE} Nothing was created or started.`,
3917
+ });
3918
+ }
2791
3919
  if (planPath !== undefined && endpoint !== null && profile?.executionMode !== "durable" &&
2792
3920
  err instanceof ConductorBridgeApiError && err.kind === "network") {
2793
3921
  const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
@@ -2795,11 +3923,17 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2795
3923
  const preflight = await deps.runPlanePreflight(deps.cwd());
2796
3924
  const blocker = firstPreconditionRefusal(preflight) ??
2797
3925
  preflight.diagnostics.find((diagnostic) => diagnostic.severity === "blocking");
2798
- preview.plan = plan;
2799
3926
  if (blocker) {
3927
+ // Keep the blocker's own evidence (e.g. Alembic revisions) and its
3928
+ // fix. The fixed sentence is only for a blocker that carries neither.
3929
+ const evidence = [blocker.message, blocker.remediation]
3930
+ .filter((part) => typeof part === "string" && part.trim() !== "")
3931
+ .join(" ");
2800
3932
  return refuseTopLevel({
2801
3933
  reasonId: blocker.check,
2802
- remedy: `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
3934
+ remedy: evidence !== ""
3935
+ ? evidence
3936
+ : `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
2803
3937
  });
2804
3938
  }
2805
3939
  if (!preflight.ok) {
@@ -2813,12 +3947,39 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2813
3947
  if (preview.decision === "create") {
2814
3948
  preview.cold_start = { plane_endpoint: access.baseUrl, will_start_plane: true, live_run_checked: false };
2815
3949
  }
3950
+ // No plane exists yet, so there is no snapshot or database to compare;
3951
+ // the host-sleep advisory still applies to the plane about to start.
3952
+ observed.access = access;
3953
+ observed.launchTopology = { kind: "local", repoRoot: null };
3954
+ await applyPreviewFreshness(ctx, preview, {}, true);
3955
+ return;
3956
+ }
3957
+ }
3958
+ // The narrow no-plan stop. Mirrors the real launch exactly: a local,
3959
+ // non-durable plane that refuses connections, and no usable plan. This is
3960
+ // the pre-existing `stop` outcome with no refusal and no new reason id; the
3961
+ // probe is the only port check the preview makes.
3962
+ if (planPath === undefined &&
3963
+ endpoint !== null &&
3964
+ profile?.executionMode !== "durable" &&
3965
+ err instanceof ConductorBridgeApiError &&
3966
+ err.kind === "network" &&
3967
+ discovery !== null &&
3968
+ (discovery.status === "plan_not_found" || discovery.status === "plan_ambiguous")) {
3969
+ const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
3970
+ if (probe.kind === "refused") {
3971
+ preview.readiness = {
3972
+ kind: "unknown",
3973
+ remedy: planeDownNoPlanStopText(epicKey, { profile, options }, discovery),
3974
+ };
3975
+ preview.refusal = null;
3976
+ preview.decision = "stop";
2816
3977
  return;
2817
3978
  }
2818
3979
  }
2819
3980
  return unknownReadiness();
2820
3981
  }
2821
- preview.plan = plan;
3982
+ observed.readiness = readiness;
2822
3983
  if (docsRootUnresolved)
2823
3984
  return refuseTopLevel(docsRootRefusal(epicKey));
2824
3985
  const live = await deps.readLiveEpicRun(access, epicKey);
@@ -2826,8 +3987,13 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2826
3987
  return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
2827
3988
  if (live.kind === "unknown")
2828
3989
  return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
2829
- const previewCtx = { ...ctx, options: { ...options, ...(planPath ? { planFile: planPath } : {}) } };
2830
- const prepared = planPath ? await prepareSetupSelection(previewCtx) : null;
3990
+ // BAPI-1532 — a DISCOVERED plan beside a live run is unused: the launch attaches,
3991
+ // so the effective plan path is cleared while the preview still reports the plan
3992
+ // as found. An explicit --plan-file keeps its create-path behavior unchanged.
3993
+ const attachingDiscoveredPlan = options.planFile === undefined && plan.status === "found" && live.kind === "live";
3994
+ const effectivePlanPath = attachingDiscoveredPlan ? undefined : planPath;
3995
+ const previewCtx = { ...ctx, options: { ...options, ...(effectivePlanPath ? { planFile: effectivePlanPath } : {}) } };
3996
+ const prepared = effectivePlanPath ? await prepareSetupSelection(previewCtx) : null;
2831
3997
  if (prepared !== null && !prepared.ok) {
2832
3998
  return refuseTopLevel({
2833
3999
  reasonId: prepared.reasonId,
@@ -2838,13 +4004,19 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2838
4004
  attended: options.attended === true,
2839
4005
  intoBase: options.intoBase === true,
2840
4006
  effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
2841
- approvedStoredPolicy: live.kind === "live" && !planPath,
4007
+ approvedStoredPolicy: live.kind === "live" && !effectivePlanPath,
2842
4008
  });
2843
4009
  preview.readiness = { kind: classification.kind };
4010
+ if (classification.kind === 'operator_blocked') {
4011
+ const requirement = V2_READINESS_REQUIREMENTS.find(req => req.id === classification.id);
4012
+ if (requirement)
4013
+ preview.launch_checks = [{ id: requirement.id, label: requirement.describe, status: 'fail', blocking: true, remedy: classification.remedy }];
4014
+ }
2844
4015
  const selection = selectConductor(readiness);
2845
4016
  // --- The live run. Conflict and unknown refuse whatever the plan says. ------
2846
4017
  if (live.kind === "live") {
2847
4018
  preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
4019
+ preview.summary.ticket_review = live.ticketReview ?? "on";
2848
4020
  if (live.effectivePolicy) {
2849
4021
  preview.summary.policy_source = live.effectivePolicy.source;
2850
4022
  preview.summary.serial_execution = live.effectivePolicy.serial_execution;
@@ -2852,11 +4024,14 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2852
4024
  preview.summary.job_timeouts = live.effectivePolicy.job_timeouts;
2853
4025
  preview.summary.escalation_channel_label = live.effectivePolicy.escalation_channel;
2854
4026
  preview.summary.posture = live.effectivePolicy.posture;
2855
- preview.summary.escalation_channel = null;
4027
+ preview.summary.escalation_channel =
4028
+ live.effectivePolicy.escalation_channel === "configured webhook"
4029
+ ? "webhook"
4030
+ : live.effectivePolicy.escalation_channel === "account email" ? "email" : null;
2856
4031
  }
2857
4032
  }
2858
4033
  // --- No plan: attach to a live run, or stop. --------------------------------
2859
- if (planPath === undefined) {
4034
+ if (effectivePlanPath === undefined) {
2860
4035
  if (live.kind !== "live") {
2861
4036
  if (selection.kind === "not_ready") {
2862
4037
  return refuseWithReadiness(classification.kind, {
@@ -2870,20 +4045,47 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
2870
4045
  const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "attach");
2871
4046
  if (blocked !== null)
2872
4047
  return refuseWithReadiness(classification.kind, blocked);
2873
- const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed");
4048
+ const attachTopology = {};
4049
+ const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed", attachTopology);
2874
4050
  if (facts !== null)
2875
4051
  return refuseWithReadiness(classification.kind, facts, true);
2876
4052
  preview.decision = "attach";
4053
+ await applyPreviewFreshness(ctx, preview, attachTopology, false);
2877
4054
  return;
2878
4055
  }
4056
+ // The create path below always has a plan.
4057
+ const planPathForCreate = effectivePlanPath;
2879
4058
  // --- A plan is available: the create path's gates, then setup's own dry run. --
2880
4059
  const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "create");
2881
- if (blocked !== null)
4060
+ if (blocked !== null) {
4061
+ // BAPI-1452: a disabled Setup-page posture keeps its exact control name and
4062
+ // turn-it-back-on remedy in the structured output as well as the refusal.
4063
+ if (classification.kind === "operator_blocked" && classification.id === "unattended_consent") {
4064
+ preview.launch_checks = previewLaunchRows(projectReadinessRowBlocking([unattendedPostureCheck(readiness)], {
4065
+ attended: options.attended === true,
4066
+ intoBase: options.intoBase === true,
4067
+ }));
4068
+ }
2882
4069
  return refuseWithReadiness(classification.kind, blocked);
2883
- const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
4070
+ }
4071
+ const topology = {};
4072
+ const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created", topology);
2884
4073
  if (facts !== null)
2885
4074
  return refuseWithReadiness(classification.kind, facts, true);
2886
- await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPath, false, refuseTopLevel, live.kind === "live");
4075
+ // BAPI-1452: the same launch evidence a real launch collects, before any
4076
+ // setup preview. A local plane that still needs its runtime will run this
4077
+ // launch on plane lanes, whose executor this launcher cannot inspect.
4078
+ const launchTopology = planeTarget && classification.kind === "runtime_only"
4079
+ ? { kind: "plane_lane" }
4080
+ : topology.value ?? { kind: "local", repoRoot: null };
4081
+ const launch = await previewLaunchCapability(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, readiness, launchTopology);
4082
+ preview.launch_checks = previewLaunchRows(launch.checks);
4083
+ if (launch.refusal !== null)
4084
+ return refuseWithReadiness("operator_blocked", launch.refusal);
4085
+ observed.access = access;
4086
+ observed.launchTopology = launchTopology;
4087
+ await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPathForCreate, false, refuseTopLevel, live.kind === "live");
4088
+ await applyPreviewFreshness(ctx, preview, topology, false);
2887
4089
  }
2888
4090
  async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTopLevel, liveRunKnown = false) {
2889
4091
  const { deps, options, access } = ctx;
@@ -2905,11 +4107,59 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
2905
4107
  : prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
2906
4108
  }
2907
4109
  preview.summary.child_count = await readPlanChildCount(deps, planPath);
2908
- const setup = await deps.previewSetupEpic({
2909
- argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
4110
+ preview.summary.children_to_run = preview.summary.child_count;
4111
+ // BAPI-1510 — the already-merged check, after plan discovery and BEFORE the
4112
+ // setup dry run, so the dry run validates the plan a real create would submit.
4113
+ // An attach, a disabled check, and a cold start (no plane to ask yet) classify
4114
+ // nothing and keep every child.
4115
+ let evaluation = null;
4116
+ if (liveRunKnown) {
4117
+ preview.already_done_children = { status: "not_checked", reason: "attach", children: [] };
4118
+ }
4119
+ else if (options.noMergedCheck === true) {
4120
+ preview.already_done_children = { status: "not_checked", reason: "disabled", children: [] };
4121
+ }
4122
+ else if (coldStart) {
4123
+ preview.already_done_children = { status: "not_checked", reason: "cold_start", children: [] };
4124
+ }
4125
+ else {
4126
+ evaluation = await evaluateMergedChildren(ctx, planPath);
4127
+ preview.already_done_children = evaluation.state;
4128
+ if (evaluation.refusals.length > 0) {
4129
+ return refuseTopLevel({
4130
+ reasonId: "skip-child-refused",
4131
+ remedy: `${skipRefusalRemedy(evaluation.refusals)} Nothing was created.`,
4132
+ });
4133
+ }
4134
+ if (evaluation.skipKeys.size > 0 && evaluation.skipKeys.size >= evaluation.childKeys.length) {
4135
+ return refuseTopLevel({
4136
+ reasonId: EPIC_ALREADY_COMPLETE_REASON_ID,
4137
+ remedy: EPIC_ALREADY_COMPLETE_REMEDY,
4138
+ });
4139
+ }
4140
+ if (preview.summary.child_count !== null) {
4141
+ preview.summary.children_to_run = preview.summary.child_count - evaluation.skipKeys.size;
4142
+ }
4143
+ }
4144
+ const trim = evaluation !== null &&
4145
+ evaluation.skipKeys.size > 0 &&
4146
+ evaluation.plan !== null &&
4147
+ evaluation.cutCommitSha !== null
4148
+ ? { plan: trimDonePlanNodes(evaluation.plan, evaluation.skipKeys), cut: evaluation.cutCommitSha }
4149
+ : null;
4150
+ const runPreviewSetup = (effectivePlanPath) => deps.previewSetupEpic({
4151
+ argv: [...buildSetupEpicArgv({ ...options, planFile: effectivePlanPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
2910
4152
  baseUrl: access.baseUrl,
2911
- handoff: prepared.handoff,
4153
+ handoff: trim !== null ? { ...prepared.handoff, expectedCutCommitSha: trim.cut } : prepared.handoff,
2912
4154
  });
4155
+ const setup = trim !== null
4156
+ ? await (deps.withPlanPath ?? withPlanPath)({
4157
+ originalPath: planPath,
4158
+ trimmedJson: JSON.stringify(trim.plan, null, 2),
4159
+ skipped: true,
4160
+ warn: (message) => deps.errorLog(message),
4161
+ }, runPreviewSetup)
4162
+ : await runPreviewSetup(planPath);
2913
4163
  if (setup.exitCode !== 0) {
2914
4164
  preview.summary.server_validation = "failed";
2915
4165
  return refuseTopLevel({
@@ -2929,6 +4179,16 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
2929
4179
  }
2930
4180
  preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
2931
4181
  preview.summary.branch = payload.featureBranch;
4182
+ if (payload.scopeBaseBranch !== null)
4183
+ previewScopeBaseBranch.set(preview, payload.scopeBaseBranch);
4184
+ preview.summary.ticket_review = payload.ticketReview;
4185
+ if (payload.launchReadiness) {
4186
+ preview.prerequisites = payload.launchReadiness.prerequisites;
4187
+ preview.is_production = payload.launchReadiness.is_production;
4188
+ const launch = classifyV2Readiness(payload.launchReadiness, { attended: options.attended, intoBase: options.intoBase });
4189
+ if (launch.kind === 'operator_blocked')
4190
+ return refuseTopLevel({ reasonId: launch.id, remedy: launch.remedy ?? 'Retry readiness.' });
4191
+ }
2932
4192
  if (payload.effectivePolicy) {
2933
4193
  const facts = payload.effectivePolicy;
2934
4194
  preview.summary.policy_source = facts.source;
@@ -2945,6 +4205,9 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
2945
4205
  preview.summary.posture === "unattended" ? payload.featureBranch : null;
2946
4206
  // The bootstrap reusing a run IS an attach: it reports the run's id.
2947
4207
  if (payload.existingRunId !== null) {
4208
+ // Existing runs are never trimmed: report the check as not applied.
4209
+ preview.already_done_children = { status: "not_checked", reason: "attach", children: [] };
4210
+ preview.summary.children_to_run = preview.summary.child_count;
2948
4211
  preview.live_run = {
2949
4212
  epic_run_id: payload.existingRunId,
2950
4213
  status: preview.live_run?.epic_run_id === payload.existingRunId ? preview.live_run.status : null,
@@ -2973,15 +4236,67 @@ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, pa
2973
4236
  }
2974
4237
  return null;
2975
4238
  }
4239
+ /**
4240
+ * Append the freshness rows to a preview and, for a decision that would CREATE a
4241
+ * run, apply the creation policy. An `attach` decision (an existing live run, or a
4242
+ * bootstrap that reuses one) only ever warns. Reads nothing for a non-plane target.
4243
+ *
4244
+ * Advisory by construction: an unexpected failure here leaves the preview exactly
4245
+ * as it was.
4246
+ */
4247
+ async function applyPreviewFreshness(ctx, preview, topology, coldStart) {
4248
+ if (preview.decision !== "create" && preview.decision !== "attach")
4249
+ return;
4250
+ const { deps } = ctx;
4251
+ if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
4252
+ return;
4253
+ try {
4254
+ const manifest = topology.manifest ?? null;
4255
+ const own = manifest !== null
4256
+ ? {
4257
+ repoRoot: manifest.repoRoot,
4258
+ ownsManifest: true,
4259
+ planeId: manifest.planeId,
4260
+ ...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
4261
+ }
4262
+ : { repoRoot: deps.cwd(), ownsManifest: false };
4263
+ const reports = coldStart ? null : await collectFreshnessObservations(ctx, own);
4264
+ // BAPI-1506: a `create` preview reports host sleep as a launch need on every
4265
+ // target (see appendLaunchNeedRows); only `attach` keeps it here.
4266
+ const sleep = preview.decision === "attach" ? await collectHostSleep(ctx, own.repoRoot) : null;
4267
+ // Read-only: one run-state read, local plane only, and only for a bound plane.
4268
+ const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
4269
+ const assessment = assessPlaneFreshness(reports, sleep, preview.decision === "create" ? "create" : "attach", ctx.options.allowStalePlane === true, boundRun);
4270
+ if (assessment.rows.length > 0) {
4271
+ preview.launch_checks = [...(preview.launch_checks ?? []), ...assessment.rows];
4272
+ }
4273
+ const refusal = freshnessRefusalOf(assessment.blockers);
4274
+ if (refusal !== null) {
4275
+ preview.decision = "refuse";
4276
+ preview.refusal = { reason_id: refusal.reasonId, remedy: refusal.remedy };
4277
+ }
4278
+ }
4279
+ catch {
4280
+ // Leave the preview as it was; nothing from the failure reaches the document.
4281
+ }
4282
+ }
2976
4283
  /**
2977
4284
  * The executor-side refusals a real launch would hit, decided read-only: the
2978
4285
  * durable profile's coverage check, else whether an executor could be started
2979
4286
  * from this checkout at all. Returns `null` when neither refuses, including when
2980
4287
  * this checkout's plane already serves the live run and needs no executor.
2981
4288
  */
2982
- async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
4289
+ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome,
4290
+ /**
4291
+ * BAPI-1452 — receives the executor topology a real launch would use.
4292
+ * BAPI-1476 — and the attachable plane manifest this checkout owns, if any, so
4293
+ * freshness compares only the selected plane's snapshot.
4294
+ */
4295
+ topologyOut) {
2983
4296
  const { deps, epicKey, access, profile } = ctx;
2984
4297
  if (profile !== null && profile.executionMode === "durable") {
4298
+ if (topologyOut)
4299
+ topologyOut.value = { kind: "durable_profile" };
2985
4300
  if (durableActorAdmissionDenied(readiness)) {
2986
4301
  return {
2987
4302
  reasonId: "actor-admission-denied",
@@ -2996,6 +4311,8 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
2996
4311
  if (planeTarget) {
2997
4312
  if (live !== null) {
2998
4313
  const manifest = await findAttachablePlane(ctx).catch(() => null);
4314
+ if (topologyOut)
4315
+ topologyOut.manifest = manifest;
2999
4316
  if (manifest?.epicRunId === live.epicRunId) {
3000
4317
  const admission = await checkPlaneAdmission(ctx, readiness, manifest);
3001
4318
  if (admission !== null) {
@@ -3005,15 +4322,140 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
3005
4322
  if (await planeServesLiveRun(ctx, live))
3006
4323
  return null;
3007
4324
  }
3008
- else if ((await findAttachablePlane(ctx).catch(() => null)) !== null) {
3009
- return null;
4325
+ else {
4326
+ const attachable = await findAttachablePlane(ctx).catch(() => null);
4327
+ if (topologyOut)
4328
+ topologyOut.manifest = attachable;
4329
+ if (attachable !== null) {
4330
+ if (topologyOut)
4331
+ topologyOut.value = { kind: "plane_lane" };
4332
+ return null;
4333
+ }
3010
4334
  }
3011
4335
  }
3012
4336
  const inspected = await deps.inspectRunExecutorPrecondition(deps.cwd());
4337
+ // BAPI-1452: the read-only inspection names the clone a run-scoped executor
4338
+ // would own; the preview never calls `prepareRunExecutor` to learn it.
4339
+ if (topologyOut && inspected.ok) {
4340
+ topologyOut.value = { kind: "local", repoRoot: inspected.repoRoot ?? null };
4341
+ }
3013
4342
  return inspected.ok
3014
4343
  ? null
3015
4344
  : executorPreconditionRefusal(epicKey, inspected.message, outcome === "No run was created" ? "No run was created." : "No run was changed.");
3016
4345
  }
4346
+ /**
4347
+ * True for the guarded plane-down no-plan stop: a `stop` that is not a refusal,
4348
+ * with unknown readiness carrying the shared handoff text as its remedy.
4349
+ */
4350
+ function isPlaneDownStop(preview) {
4351
+ return (preview.decision === "stop" &&
4352
+ preview.refusal === null &&
4353
+ preview.readiness.kind === "unknown" &&
4354
+ typeof preview.readiness.remedy === "string" &&
4355
+ preview.readiness.remedy !== "");
4356
+ }
4357
+ /**
4358
+ * The escalation-reachability row as text, directly under the `Escalation:` line.
4359
+ * An explicit `WARN` or `PASS` word carries the meaning (never colour), and the
4360
+ * explanation and fix sit on their own indented lines so a narrow terminal wraps
4361
+ * them beneath the label.
4362
+ */
4363
+ function reachabilityLines(preview) {
4364
+ const row = preview.launch_checks?.find((entry) => entry.id === ESCALATION_REACHABILITY_CHECK_ID);
4365
+ if (row === undefined)
4366
+ return [];
4367
+ return [
4368
+ ` ${row.status === "pass" ? "PASS" : "WARN"} ${ESCALATION_REACHABILITY_CHECK_ID}`,
4369
+ ...(row.detail ? [` ${row.detail}`] : []),
4370
+ ...(row.remedy ? [` Fix: ${row.remedy}`] : []),
4371
+ ];
4372
+ }
4373
+ /** Operator wording for each reason a child that was checked could not be proven merged. */
4374
+ const UNKNOWN_REASON_TEXT = {
4375
+ ancestry_unknown: "its merge could not be compared with the indexed commit",
4376
+ binding_ambiguous: "its delivery records name more than one pull request",
4377
+ provider_unavailable: "the code host could not be read",
4378
+ binding_read_failed: "its delivery records could not be read",
4379
+ merge_sha_unavailable: "its merge commit could not be confirmed",
4380
+ deadline_exceeded: "the check ran out of time",
4381
+ };
4382
+ const NOT_CHECKED_TEXT = {
4383
+ disabled: "Merged check: turned off (--no-merged-check). Every child will run.",
4384
+ attach: "Merged check: not applied; existing runs are unchanged.",
4385
+ cold_start: "Merged check: will be checked at create.",
4386
+ cut_commit_unavailable: "Merged check: the indexed commit could not be read. Every child will run.",
4387
+ check_failed: "Merged check: could not complete. Every child will run.",
4388
+ no_plan: null,
4389
+ };
4390
+ /**
4391
+ * The already-merged summary for the human preview (BAPI-1510). Leads with the
4392
+ * children to run and skipped, then the groups in a fixed order. Meaning is carried
4393
+ * by words, never colour; only customer ticket keys and validated PR numbers appear.
4394
+ */
4395
+ function alreadyDoneLines(preview) {
4396
+ const s = preview.summary;
4397
+ const state = preview.already_done_children ?? { status: "not_checked", reason: "no_plan", children: [] };
4398
+ const lines = [];
4399
+ if (preview.refusal?.reason_id === EPIC_ALREADY_COMPLETE_REASON_ID) {
4400
+ lines.push(" Status: no work to run — every child is already merged", ` ${preview.refusal.remedy}`);
4401
+ return lines;
4402
+ }
4403
+ const skipped = state.children.filter((child) => child.will_skip);
4404
+ if (state.status === "checked" || s.child_count !== null) {
4405
+ if (s.children_to_run !== null && s.children_to_run !== undefined) {
4406
+ lines.push(` To run: ${s.children_to_run}`);
4407
+ }
4408
+ lines.push(` Skipped: ${skipped.length}`);
4409
+ }
4410
+ if (state.status === "not_checked") {
4411
+ const text = NOT_CHECKED_TEXT[state.reason];
4412
+ if (text !== null)
4413
+ lines.push(` ${text}`);
4414
+ return lines;
4415
+ }
4416
+ const pr = (child) => child.pr_number !== null && Number.isSafeInteger(child.pr_number) && child.pr_number > 0
4417
+ ? ` (#${child.pr_number})`
4418
+ : "";
4419
+ if (skipped.length > 0) {
4420
+ lines.push(" Skipped (already merged):");
4421
+ for (const child of skipped) {
4422
+ lines.push(` ${child.ticket_key}${pr(child)} — ${child.classification === "done" ? "already merged" : "confirmed skip"}`);
4423
+ }
4424
+ }
4425
+ const probably = state.children.filter((child) => child.classification === "probably_done" && !child.will_skip);
4426
+ if (probably.length > 0) {
4427
+ lines.push(" Probably done — confirmation needed:");
4428
+ for (const child of probably) {
4429
+ lines.push(` ${child.ticket_key}${pr(child)} — on the indexed commit but has no delivery record; it will run. ` +
4430
+ `To skip it, pass --skip-child ${child.ticket_key}.`);
4431
+ }
4432
+ }
4433
+ const behind = state.children.filter((child) => child.reason === "merged_after_index");
4434
+ for (const child of behind) {
4435
+ lines.push(` ${child.ticket_key}${pr(child)} — merged after the indexed commit; it will run. ` +
4436
+ "Re-index the repository, or promote the branch it merged into, to skip it.");
4437
+ }
4438
+ const unknown = state.children.filter((child) => child.classification === "unknown");
4439
+ if (unknown.some((child) => child.reason === "ancestry_unavailable_for_provider")) {
4440
+ lines.push(" Merged-child detection is not supported for this repository's code host; every child will run.");
4441
+ }
4442
+ const byReason = new Map();
4443
+ for (const child of unknown) {
4444
+ if (child.reason === "ancestry_unavailable_for_provider")
4445
+ continue;
4446
+ byReason.set(child.reason, [...(byReason.get(child.reason) ?? []), child.ticket_key]);
4447
+ }
4448
+ for (const [reason, keys] of byReason) {
4449
+ lines.push(` Could not confirm ${keys.join(", ")}: ${UNKNOWN_REASON_TEXT[reason] ?? "the check was inconclusive"}. ` +
4450
+ `${keys.length === 1 ? "It" : "They"} will run.`);
4451
+ }
4452
+ if (skipped.length > 0 && s.children_to_run === 1) {
4453
+ lines.push(s.branch !== null
4454
+ ? ` Strategy: one child remains and runs on ${s.branch}`
4455
+ : " Strategy: one child remains and runs directly on the repository base branch");
4456
+ }
4457
+ return lines;
4458
+ }
3017
4459
  /**
3018
4460
  * Print the preview: one JSON line with `--json`, short readable lines otherwise.
3019
4461
  *
@@ -3035,8 +4477,13 @@ async function emitPreview(deps, options, preview) {
3035
4477
  ` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
3036
4478
  ` Live run: ${preview.live_run
3037
4479
  ? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
3038
- : preview.cold_start ? "unchecked (plane not yet running)" : "none"}`,
3039
- ` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
4480
+ : preview.cold_start ? "unchecked (plane not yet running)"
4481
+ : isPlaneDownStop(preview) ? "unchecked (plane not running)" : "none"}`,
4482
+ // BAPI-1532 — a plan found beside a live run is "Found, unused": the launch
4483
+ // attaches and the local file is not read as approved input.
4484
+ ` Plan: ${preview.plan.status === "found" && preview.decision === "attach"
4485
+ ? "Found, unused"
4486
+ : preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
3040
4487
  ];
3041
4488
  for (const candidate of preview.plan.candidates ?? [])
3042
4489
  lines.push(` candidate: ${candidate}`);
@@ -3044,15 +4491,35 @@ async function emitPreview(deps, options, preview) {
3044
4491
  lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
3045
4492
  }
3046
4493
  const s = preview.summary;
3047
- lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
3048
- if (preview.readiness.remedy &&
4494
+ lines.push(` Ticket review: ${s.ticket_review ?? "on"}`, ` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ...alreadyDoneLines(preview), ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ...reachabilityLines(preview), ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
4495
+ if (isPlaneDownStop(preview)) {
4496
+ // The shared stop text, verbatim and unindented, so it reads exactly as the
4497
+ // real launch prints it. It is a stop, not a refusal.
4498
+ lines.push("", preview.readiness.remedy);
4499
+ }
4500
+ else if (preview.readiness.remedy &&
3049
4501
  preview.readiness.remedy !== preview.refusal?.remedy &&
3050
4502
  !preview.refusal?.reason_id.startsWith("access-")) {
3051
4503
  lines.push(` Remedy: ${preview.readiness.remedy}`);
3052
4504
  }
3053
- if (preview.refusal) {
4505
+ if (preview.refusal && preview.refusal.reason_id !== EPIC_ALREADY_COMPLETE_REASON_ID) {
3054
4506
  lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
3055
4507
  }
4508
+ const summaryRows = (preview.launch_checks ?? []).filter(row => row.id !== ESCALATION_REACHABILITY_CHECK_ID);
4509
+ lines.unshift(...formatLaunchReadinessSummary(summaryRows.map(row => ({
4510
+ id: row.id, source: row.id.startsWith('server.') ? 'server' : 'conductor',
4511
+ label: row.label, status: row.status, blocking: row.blocking,
4512
+ ...(row.detail ? { detail: row.detail } : {}),
4513
+ ...(row.remedy ? { remediation: row.remedy } : {}),
4514
+ })), { all: options.all, isProduction: preview.is_production, prerequisites: preview.prerequisites }));
4515
+ const localRows = summaryRows.filter(row => !READINESS_PREREQUISITE_BY_ID.has(READINESS_LEGACY_IDS[row.id] ?? row.id));
4516
+ // BAPI-1506: launch-need rows get their own heading; they are not plane freshness.
4517
+ const freshnessRows = localRows.filter(row => row.launch_need === undefined);
4518
+ const launchNeedRows = localRows.filter(row => row.launch_need !== undefined);
4519
+ if (freshnessRows.length)
4520
+ lines.push(...renderFreshnessRows(freshnessRows));
4521
+ if (launchNeedRows.length)
4522
+ lines.push(...renderFreshnessRows(launchNeedRows, 'Launch needs (advisory; never blocks):'));
3056
4523
  deps.log(lines.join("\n"));
3057
4524
  return true;
3058
4525
  }