@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,5 @@
1
+ import { ticketReviewSummary } from "./ticket-review-summary.js";
2
+ import { previewLaunchReadiness } from "./conductor/bridge-api-client.js";
1
3
  /**
2
4
  * `setup-epic` — one-command bootstrap for an Epic Implementer run.
3
5
  *
@@ -82,7 +84,7 @@
82
84
  import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
83
85
  import os from "node:os";
84
86
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
85
- import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, safeDiagnosticMessage, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
87
+ import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, formatSchemaIncompatibleRefusal, isSchemaIncompatibleError, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, safeDiagnosticMessage, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
86
88
  import { formatEpicLaunchChannelIdentity, getPinnedEpicLaunchPolicy, readEpicLaunchProfile, } from "./epic-launch-profile.js";
87
89
  // BAPI-1133 (A2) — the single legacy-to-typed merge-execution-mode mapping and
88
90
  // its total legacy resolver live in `merge-pull-request.ts` (no import cycle:
@@ -94,6 +96,7 @@ import { classifyMergeExecutionEligibility } from "./conductor/merge-execution-e
94
96
  import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
95
97
  import { resolveTrackedPaths } from "./git-reachability.js";
96
98
  import { validateBranchName } from "./base-ref.js";
99
+ import { TICKET_KEY_PATTERN } from "./ticket-key-utils.js";
97
100
  // Reused from the implement-epic client on purpose: adding a second wrapper under
98
101
  // `conductor/` would give the v2 path its own copy to drift from.
99
102
  //
@@ -113,6 +116,134 @@ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
113
116
  // state. See `planeBinding` on `SetupEpicDeps` below.
114
117
  import { createPlaneFsDeps, createPlaneProcessDeps } from "./plane/defaults.js";
115
118
  import { bindPlaneManifestEpicRun, manifestHasLiveProcess, readPlaneManifest, } from "./plane/manifest.js";
119
+ // ---------------------------------------------------------------------------
120
+ // Verification hand-off choice (BAPI-1496)
121
+ // ---------------------------------------------------------------------------
122
+ /** The policy keys child 1 (BAPI-1492) declares on the server's `RunPolicy`. */
123
+ export const SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY = "verification_handoff_enabled";
124
+ export const SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY = "verification_handoff_exceptions";
125
+ /**
126
+ * The closed step-class vocabulary. Mirrors the server's
127
+ * `VERIFICATION_HANDOFF_STEP_CLASSES` in `api/models/epic_run.py`; a client
128
+ * cannot import a Python constant, so a test pins the two together.
129
+ */
130
+ export const SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES = ["e2e_tests", "rendered_ui_review"];
131
+ /** Mirrors the server's `MAX_VERIFICATION_HANDOFF_EXCEPTIONS`. */
132
+ export const SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS = 200;
133
+ /**
134
+ * Deduplicate and order exception pairs deterministically, by ticket key and
135
+ * then class, so the same set always composes the same bytes.
136
+ */
137
+ export function normalizeVerificationHandoffExceptions(entries) {
138
+ const seen = new Map();
139
+ for (const entry of entries) {
140
+ seen.set(`${entry.ticket_key}\u0000${entry.step_class}`, {
141
+ ticket_key: entry.ticket_key,
142
+ step_class: entry.step_class,
143
+ });
144
+ }
145
+ return [...seen.values()].sort((a, b) => a.ticket_key === b.ticket_key
146
+ ? a.step_class.localeCompare(b.step_class)
147
+ : a.ticket_key.localeCompare(b.ticket_key));
148
+ }
149
+ /**
150
+ * Validate the raw flag values. Every refusal names the offending flag and
151
+ * repeats only CLI-supplied text, before any file read or request.
152
+ */
153
+ export function parseVerificationHandoffFlags(args) {
154
+ if (args.choice === undefined && args.exceptions.length === 0)
155
+ return { ok: true, value: undefined };
156
+ if (args.choice === undefined) {
157
+ return {
158
+ ok: false,
159
+ error: "--verification-handoff-exception requires --verification-handoff block. An exception " +
160
+ "only narrows a hand-off choice, so without one there is nothing to narrow.",
161
+ };
162
+ }
163
+ if (args.choice !== "proceed" && args.choice !== "block") {
164
+ return {
165
+ ok: false,
166
+ error: `--verification-handoff must be one of: proceed, block. Got '${args.choice}'.`,
167
+ };
168
+ }
169
+ if (args.attended || args.intoBase) {
170
+ const flag = args.attended ? "--attended" : "--into-base";
171
+ return {
172
+ ok: false,
173
+ error: `--verification-handoff cannot be combined with ${flag}: a verification hand-off ` +
174
+ "needs an unattended epic-branch run, and this launch has no such authority. " +
175
+ "Drop --verification-handoff; verification stays a blocker.",
176
+ };
177
+ }
178
+ const parsed = [];
179
+ for (const raw of args.exceptions) {
180
+ const separator = raw.lastIndexOf(":");
181
+ const ticketKey = separator > 0 ? raw.slice(0, separator) : "";
182
+ const stepClass = separator > 0 ? raw.slice(separator + 1) : "";
183
+ if (!TICKET_KEY_PATTERN.test(ticketKey)) {
184
+ return {
185
+ ok: false,
186
+ error: `--verification-handoff-exception '${raw}' must be <TICKET>:<class> with a ticket key such as PROJ-12.`,
187
+ };
188
+ }
189
+ if (!SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.includes(stepClass)) {
190
+ return {
191
+ ok: false,
192
+ error: `--verification-handoff-exception '${raw}' names an unsupported class. Use one of: ` +
193
+ `${SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.join(", ")}.`,
194
+ };
195
+ }
196
+ parsed.push({ ticket_key: ticketKey, step_class: stepClass });
197
+ }
198
+ if (args.choice === "proceed" && parsed.length > 0) {
199
+ return {
200
+ ok: false,
201
+ error: "--verification-handoff-exception cannot be combined with --verification-handoff proceed. " +
202
+ "Use --verification-handoff block to keep verification a blocker for named work.",
203
+ };
204
+ }
205
+ if (args.choice === "block" && parsed.length === 0) {
206
+ return {
207
+ ok: false,
208
+ error: "--verification-handoff block needs at least one --verification-handoff-exception " +
209
+ "<TICKET>:<class> naming the work whose verification stays a blocker.",
210
+ };
211
+ }
212
+ const exceptions = normalizeVerificationHandoffExceptions(parsed);
213
+ if (exceptions.length > SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS) {
214
+ return {
215
+ ok: false,
216
+ error: `--verification-handoff-exception names ${exceptions.length} distinct entries; the run ` +
217
+ `policy holds at most ${SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS}. Nothing was created.`,
218
+ };
219
+ }
220
+ return { ok: true, value: { choice: args.choice, exceptions } };
221
+ }
222
+ /** The two policy keys a validated choice composes. */
223
+ export function verificationHandoffPolicyKeys(choice) {
224
+ return {
225
+ [SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY]: true,
226
+ [SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY]: choice.exceptions.map((entry) => ({ ...entry })),
227
+ };
228
+ }
229
+ /**
230
+ * Read a policy file's declared exception list as a normalized pair set, or
231
+ * `null` when it is not a well-shaped list. Never returns or echoes a value.
232
+ */
233
+ function readDeclaredHandoffExceptions(value) {
234
+ if (!Array.isArray(value))
235
+ return null;
236
+ const entries = [];
237
+ for (const entry of value) {
238
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
239
+ return null;
240
+ const { ticket_key: ticketKey, step_class: stepClass } = entry;
241
+ if (typeof ticketKey !== "string" || typeof stepClass !== "string")
242
+ return null;
243
+ entries.push({ ticket_key: ticketKey, step_class: stepClass });
244
+ }
245
+ return normalizeVerificationHandoffExceptions(entries);
246
+ }
116
247
  /**
117
248
  * The one `policy_json.posture` value that means "unattended" (BAPI-1102).
118
249
  *
@@ -225,7 +356,7 @@ export function getSetupEpicUsage() {
225
356
  "default branch. `drive-epic` readiness lists all three with their current state.",
226
357
  "",
227
358
  "Required:",
228
- " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
359
+ " --epic-key <KEY> Epic key (e.g. PROJ-123)",
229
360
  " --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
230
361
  "",
231
362
  "Options:",
@@ -248,14 +379,13 @@ export function getSetupEpicUsage() {
248
379
  " the default would leave on base.",
249
380
  " --into-base OPT OUT of the epic-branch default: cut no branch and",
250
381
  " target every child PR at the repository base branch",
251
- " (the pre-BAPI-1009 behavior). Use this if automation",
382
+ " (children target the base branch). Use this if automation",
252
383
  " depended on \"no flag = direct to base\". Contradictory",
253
384
  " with --feature-branch; passing both is a parse error.",
254
385
  " --attended OPT OUT of the unattended default: compose only",
255
386
  " base_branch (plus --review-policy when supplied), with",
256
387
  " no posture and no auto-merge authorization, and skip the",
257
- " unattended repository prerequisites entirely. The create",
258
- " request is byte-for-byte the pre-BAPI-1102 one.",
388
+ " unattended repository prerequisites entirely.",
259
389
  " Contradictory with a --policy-file that declares",
260
390
  " posture: unattended; that pair is refused by name.",
261
391
  " --review-policy <src> PER-RUN review policy source, one of:",
@@ -272,12 +402,22 @@ export function getSetupEpicUsage() {
272
402
  " default is stamped into a new run only when the run",
273
403
  " sets no review policy of its own, so a value supplied",
274
404
  " here always wins.",
405
+ " --verification-handoff proceed|block",
406
+ " The owner's answer to the setup gap question,",
407
+ " for a NEW unattended epic-branch run. proceed composes",
408
+ " verification_handoff_enabled: true with no exceptions;",
409
+ " block composes it with the named exceptions. Refused with",
410
+ " --attended, --into-base, a launch with no epic branch, or",
411
+ " a live run (use patch-policy). Omitted, no key is sent.",
412
+ " --verification-handoff-exception <TICKET>:<class>",
413
+ ` Repeatable; class is one of ${SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.join(", ")}.`,
414
+ " Required with block, refused with proceed. At most",
415
+ ` ${SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS} distinct entries.`,
275
416
  " --json Emit a single JSON result object on stdout",
276
- " --launch-profile <name> (BAPI-1260) Resolve the target, repository, and bound",
277
- " credential from this host-local launch profile. Passed",
278
- " by drive-epic. The profile names its own target; it",
279
- " never falls back to production by itself.",
280
- " --base-url <url> (BAPI-1260) Explicit target; must agree with the profile.",
417
+ " --launch-profile <name> Passed by drive-epic: resolve the target, repository, and",
418
+ " credential from a host-local launch profile (see",
419
+ " `drive-epic --help-advanced`).",
420
+ " --base-url <url> Explicit target; must agree with the profile.",
281
421
  " -h, --help Show this help",
282
422
  "",
283
423
  "Policy controls:",
@@ -290,8 +430,7 @@ export function getSetupEpicUsage() {
290
430
  " type's gate ceiling (implement / spec_review / resume",
291
431
  " 10799; remediate / ci_fix 3599; rebase / smoke 2699;",
292
432
  " merge 1799); the server rejects a higher value with a",
293
- " 422 naming the offending field. Full table:",
294
- " docs/epic-implementer/operator-runbook.md §4.",
433
+ " 422 naming the offending field.",
295
434
  " --replace-policy Authorize replacing a LIVE run's stored policy with the",
296
435
  " policy file, as a complete replacement. Without it, a",
297
436
  " divergent policy on a reused run is refused with a",
@@ -380,8 +519,26 @@ export function parseSetupEpicArgs(argv) {
380
519
  let json = false;
381
520
  let launchProfile;
382
521
  let baseUrl;
522
+ // BAPI-1496 — raw hand-off values; validated together after the loop.
523
+ let verificationHandoffChoice;
524
+ const verificationHandoffExceptions = [];
383
525
  for (let i = 0; i < argv.length; i++) {
384
526
  const arg = argv[i];
527
+ // BAPI-1496 — inline forms of the hand-off flags.
528
+ if (arg.startsWith("--verification-handoff=")) {
529
+ verificationHandoffChoice = arg.slice("--verification-handoff=".length).trim();
530
+ if (verificationHandoffChoice === "") {
531
+ return { status: "error", message: "--verification-handoff requires a value." };
532
+ }
533
+ continue;
534
+ }
535
+ if (arg.startsWith("--verification-handoff-exception=")) {
536
+ const raw = arg.slice("--verification-handoff-exception=".length).trim();
537
+ if (raw === "")
538
+ return { status: "error", message: "--verification-handoff-exception requires a value." };
539
+ verificationHandoffExceptions.push(raw);
540
+ continue;
541
+ }
385
542
  // `--policy-file=<path>` inline form (BAPI-754).
386
543
  if (arg.startsWith("--policy-file=")) {
387
544
  const raw = arg.slice("--policy-file=".length).trim();
@@ -494,6 +651,18 @@ export function parseSetupEpicArgs(argv) {
494
651
  i++;
495
652
  break;
496
653
  }
654
+ case "--verification-handoff":
655
+ case "--verification-handoff-exception": {
656
+ const v = takeValue(argv, i, arg);
657
+ if (v === null || v.trim() === "")
658
+ return { status: "error", message: `${arg} requires a value.` };
659
+ if (arg === "--verification-handoff")
660
+ verificationHandoffChoice = v.trim();
661
+ else
662
+ verificationHandoffExceptions.push(v.trim());
663
+ i++;
664
+ break;
665
+ }
497
666
  case "--into-base":
498
667
  intoBase = true;
499
668
  break;
@@ -551,6 +720,16 @@ export function parseSetupEpicArgs(argv) {
551
720
  "may be selected — remove whichever one you did not mean.",
552
721
  };
553
722
  }
723
+ // BAPI-1496 — the hand-off choice, its pairing rules, and its topology rule,
724
+ // refused here before any file read, credential resolution, or request.
725
+ const handoff = parseVerificationHandoffFlags({
726
+ choice: verificationHandoffChoice,
727
+ exceptions: verificationHandoffExceptions,
728
+ attended,
729
+ intoBase,
730
+ });
731
+ if (!handoff.ok)
732
+ return { status: "error", message: handoff.error };
554
733
  if (replacePolicy && policyFile === undefined) {
555
734
  return {
556
735
  status: "error",
@@ -578,6 +757,8 @@ export function parseSetupEpicArgs(argv) {
578
757
  // BAPI-1260 — only when supplied, so a legacy options object is unchanged.
579
758
  ...(launchProfile ? { launchProfile } : {}),
580
759
  ...(baseUrl ? { baseUrl } : {}),
760
+ // BAPI-1496 — only when supplied, so a legacy options object is unchanged.
761
+ ...(handoff.value !== undefined ? { verificationHandoff: handoff.value } : {}),
581
762
  },
582
763
  };
583
764
  }
@@ -715,6 +896,39 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
715
896
  : { source: selections.reviewPolicy };
716
897
  }
717
898
  }
899
+ // BAPI-1496 — the hand-off keys under the same "no silent winner" rule. No
900
+ // choice leaves the file's values exactly as written; a choice fills only an
901
+ // absent key and refuses any declared disagreement. Exception lists compare as
902
+ // (ticket_key, step_class) sets. Neither value is echoed.
903
+ if (selections.verificationHandoff !== undefined) {
904
+ const wanted = verificationHandoffPolicyKeys(selections.verificationHandoff);
905
+ const enabledKey = SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY;
906
+ const exceptionsKey = SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY;
907
+ const flag = `--verification-handoff ${selections.verificationHandoff.choice}`;
908
+ if (enabledKey in composed && composed[enabledKey] !== wanted[enabledKey]) {
909
+ return {
910
+ ok: false,
911
+ error: `${flag} turns the verification hand-off on, but --policy-file declares a different ` +
912
+ `${enabledKey}. setup-epic will not pick a winner between them: drop the flag, or fix ` +
913
+ `the policy file so both agree.`,
914
+ };
915
+ }
916
+ if (exceptionsKey in composed) {
917
+ const declared = readDeclaredHandoffExceptions(composed[exceptionsKey]);
918
+ if (declared === null || JSON.stringify(declared) !== JSON.stringify(wanted[exceptionsKey])) {
919
+ return {
920
+ ok: false,
921
+ error: `${flag} selects a set of verification exceptions, but --policy-file declares a ` +
922
+ `different ${exceptionsKey}. setup-epic will not pick a winner between them: drop ` +
923
+ `the flag, or fix the policy file so both agree.`,
924
+ };
925
+ }
926
+ }
927
+ for (const key of [enabledKey, exceptionsKey]) {
928
+ if (!(key in composed))
929
+ composed[key] = wanted[key];
930
+ }
931
+ }
718
932
  return { ok: true, policy: composed };
719
933
  }
720
934
  /**
@@ -773,7 +987,27 @@ export function preflightSetupEpicPolicy(args) {
773
987
  `declaration from the policy file.`,
774
988
  };
775
989
  }
776
- // 3. Policy-source contradictions between the file and the CLI selections.
990
+ // 3. BAPI-1496 — a hand-off choice needs an unattended epic-branch launch.
991
+ // `--attended` and `--into-base` are already parse errors; this covers the
992
+ // topology resolved from the plan and the file: no epic branch at all, or a
993
+ // file whose declared posture is not the unattended one.
994
+ if (args.verificationHandoff !== undefined) {
995
+ const declaresOtherPosture = args.filePolicy !== undefined &&
996
+ "posture" in args.filePolicy &&
997
+ args.filePolicy.posture !== SETUP_EPIC_UNATTENDED_POSTURE;
998
+ if ((args.featureBranch === undefined && !args.policyDeclaresBranch) || declaresOtherPosture) {
999
+ return {
1000
+ ok: false,
1001
+ reason: "verification_handoff_without_authority",
1002
+ message: "--verification-handoff needs an unattended run on an epic branch, and this launch " +
1003
+ (declaresOtherPosture
1004
+ ? `is not unattended: --policy-file '${args.policyFile}' declares another posture. `
1005
+ : "selects no epic branch. ") +
1006
+ "Drop --verification-handoff; verification stays a blocker.",
1007
+ };
1008
+ }
1009
+ }
1010
+ // 4. Policy-source contradictions between the file and the CLI selections.
777
1011
  // Delegated to the composer, which owns the "no silent winner" rule; the
778
1012
  // gate only gives the outcome a stable name.
779
1013
  if (args.filePolicy === undefined)
@@ -781,6 +1015,7 @@ export function preflightSetupEpicPolicy(args) {
781
1015
  const composed = composeSetupEpicPolicy(args.filePolicy, {
782
1016
  featureBranch: args.featureBranch,
783
1017
  reviewPolicy: args.reviewPolicy,
1018
+ ...(args.verificationHandoff !== undefined ? { verificationHandoff: args.verificationHandoff } : {}),
784
1019
  });
785
1020
  if (!composed.ok) {
786
1021
  return { ok: false, reason: "policy_source_contradiction", message: composed.error };
@@ -1144,7 +1379,48 @@ function findCycle(keys, adjacency) {
1144
1379
  }
1145
1380
  return null;
1146
1381
  }
1147
- export function effectivePolicyFacts(policy, source) {
1382
+ /**
1383
+ * Count the distinct ordering relationships a validated plan declares, through
1384
+ * either supported representation: top-level `edges` and per-node `depends_on`.
1385
+ * One pair is counted once however many places declare it. Empty IDs and
1386
+ * self-loops are ignored; node existence is validated elsewhere, not here.
1387
+ */
1388
+ function countDeclaredOrderingPairs(plan) {
1389
+ const pairs = new Set();
1390
+ const add = (from, to) => {
1391
+ if (typeof from !== "string" || typeof to !== "string")
1392
+ return;
1393
+ if (from === "" || to === "" || from === to)
1394
+ return;
1395
+ pairs.add(`${from}->${to}`);
1396
+ };
1397
+ for (const edge of Array.isArray(plan.edges) ? plan.edges : [])
1398
+ add(edge?.from, edge?.to);
1399
+ for (const node of Array.isArray(plan.nodes) ? plan.nodes : []) {
1400
+ for (const dependency of Array.isArray(node?.depends_on) ? node.depends_on : []) {
1401
+ add(dependency, node.ticket_key);
1402
+ }
1403
+ }
1404
+ return pairs.size;
1405
+ }
1406
+ /**
1407
+ * The channel a STORED policy resolves to, from the engine's own
1408
+ * `notification_sink` facts. The stored policy is server-redacted, so its
1409
+ * `notify` block cannot answer this. Strict `=== true` comparisons: a truthy
1410
+ * non-boolean is unavailable, never a guess.
1411
+ */
1412
+ function storedEscalationChannel(notificationSink) {
1413
+ if (!notificationSink || typeof notificationSink !== "object" || Array.isArray(notificationSink)) {
1414
+ return "unavailable";
1415
+ }
1416
+ const sink = notificationSink;
1417
+ if (sink.webhook_configured === true)
1418
+ return "configured webhook";
1419
+ if (sink.email_channel_selected === true)
1420
+ return "account email";
1421
+ return "unavailable";
1422
+ }
1423
+ export function effectivePolicyFacts(policy, source, notificationSink) {
1148
1424
  const object = policy && typeof policy === "object" && !Array.isArray(policy)
1149
1425
  ? policy : {};
1150
1426
  const numbers = (value) => {
@@ -1159,7 +1435,9 @@ export function effectivePolicyFacts(policy, source) {
1159
1435
  posture: object.posture === "unattended" ? "unattended" : "attended",
1160
1436
  caps: numbers(object.caps),
1161
1437
  job_timeouts: numbers(object.job_timeouts),
1162
- escalation_channel: formatEpicLaunchChannelIdentity(source === "stored" ? "unavailable" : policyDeclaresNotifyWebhook(object) ? "configured webhook" : "account email"),
1438
+ escalation_channel: formatEpicLaunchChannelIdentity(source === "stored"
1439
+ ? storedEscalationChannel(notificationSink)
1440
+ : policyDeclaresNotifyWebhook(object) ? "configured webhook" : "account email"),
1163
1441
  };
1164
1442
  }
1165
1443
  /** Propose a conductor epic feature branch name for an epic key (BAPI-655). */
@@ -1351,7 +1629,7 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
1351
1629
  message: "unattended runs are off for this repository. Turn on 'Allow unattended " +
1352
1630
  "Epic Implementer runs' on the repository's Setup page in the Bridge admin " +
1353
1631
  "panel, or pass --attended to create an attended run. If it is already on, " +
1354
- "contact Bridge support. No run was created.",
1632
+ "email info@bridgegpt.ai. No run was created.",
1355
1633
  };
1356
1634
  }
1357
1635
  // Two independent sources satisfy the escalation channel, and the check has to
@@ -1411,7 +1689,7 @@ export function evaluateSetupEpicBringUpPrerequisites(readiness) {
1411
1689
  if (readiness === null) {
1412
1690
  return {
1413
1691
  reason: "readiness_unreadable",
1414
- message: "the Bridge API conductor readiness report could not be read, so the database " +
1692
+ message: "the Epic Implementer readiness report could not be read, so the database " +
1415
1693
  "migration, worker presence, and single-reconciler facts could not be confirmed. " +
1416
1694
  `Retry once the server is reachable. ${NO_RUN_WAS_CREATED}`,
1417
1695
  };
@@ -1462,8 +1740,8 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
1462
1740
  if (readiness === null) {
1463
1741
  return {
1464
1742
  reason: MERGE_EXECUTION_INELIGIBLE,
1465
- message: "this run declares merge_execution_mode: github_actions, and the Bridge API " +
1466
- "conductor readiness report could not be read, so the repository's merge " +
1743
+ message: "this run declares merge_execution_mode: github_actions, and the Epic " +
1744
+ "Implementer readiness report could not be read, so the repository's merge " +
1467
1745
  "capability could not be confirmed. Retry once the server is reachable. If " +
1468
1746
  "readiness then reports the merge workflow missing or out of date, resolve it " +
1469
1747
  `with \`${MERGE_WORKFLOW_REMEDY_COMMAND}\`. ${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ` +
@@ -1555,6 +1833,9 @@ export function composeDefaultSetupEpicPolicy(args) {
1555
1833
  policy[SETUP_EPIC_INTEGRATION_PREPARATION_POLICY_KEY] = {
1556
1834
  ...SETUP_EPIC_INTEGRATION_PREPARATION_DEFAULT,
1557
1835
  };
1836
+ if (args.verificationHandoff !== undefined) {
1837
+ Object.assign(policy, verificationHandoffPolicyKeys(args.verificationHandoff));
1838
+ }
1558
1839
  }
1559
1840
  }
1560
1841
  if (args.reviewPolicy !== undefined) {
@@ -1698,13 +1979,13 @@ async function applyReusedRunPolicy(args) {
1698
1979
  });
1699
1980
  if (outcome.kind === "noop") {
1700
1981
  args.result.policy_applied = "noop";
1701
- args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
1982
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
1702
1983
  args.say("Policy: policy unchanged");
1703
1984
  return true;
1704
1985
  }
1705
1986
  if (outcome.kind === "refused_divergent") {
1706
1987
  args.result.policy_applied = "refused_divergent";
1707
- args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
1988
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
1708
1989
  args.deps.errorLog(`Epic run ${args.epicRunId} is live and its stored policy differs from the ` +
1709
1990
  `policy file. setup-epic will not silently change a running epic's policy.\n` +
1710
1991
  `${outcome.diffLines.join("\n")}\n` +
@@ -1717,7 +1998,7 @@ async function applyReusedRunPolicy(args) {
1717
1998
  }
1718
1999
  catch (err) {
1719
2000
  args.result.policy_applied = "refused_divergent";
1720
- args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
2001
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
1721
2002
  args.deps.errorLog(`Failed to replace the run policy: ${errorDetail(err)}`);
1722
2003
  return false;
1723
2004
  }
@@ -1981,6 +2262,9 @@ export async function runSetupEpicWorkflow(argv, overrides = {}, handoff) {
1981
2262
  exitCode,
1982
2263
  ...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
1983
2264
  ...(sink.epicRunId !== undefined ? { runExists: true } : {}),
2265
+ ...(sink.runCreated !== undefined ? { runCreated: sink.runCreated } : {}),
2266
+ ...(sink.planStored !== undefined ? { planStored: sink.planStored } : {}),
2267
+ ...(sink.reusedExistingRun !== undefined ? { reusedExistingRun: sink.reusedExistingRun } : {}),
1984
2268
  };
1985
2269
  }
1986
2270
  /**
@@ -2081,12 +2365,16 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2081
2365
  }
2082
2366
  }
2083
2367
  let existingRunId = null;
2368
+ let existingTicketReview;
2084
2369
  let existingStatus = null;
2085
2370
  let existingBaseBranch = null;
2086
2371
  let existingReviewPolicy = null;
2087
2372
  // BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
2088
2373
  // comparison. Redacted is the only form a client can ever see.
2089
2374
  let existingPolicyJson = null;
2375
+ // The same response's engine-reported notification facts (never the redacted
2376
+ // policy's `notify`), for the stored-policy channel label.
2377
+ let existingNotificationSink = null;
2090
2378
  if (!opts.localOnly) {
2091
2379
  // --- Resolve access ------------------------------------------------------
2092
2380
  // BAPI-1260: a caller that selected the epic-launch contract (drive-epic
@@ -2116,7 +2404,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2116
2404
  access = accessResult.access;
2117
2405
  say(`Epic: ${opts.epicKey}`);
2118
2406
  say(`Repo: ${access.repoName}`);
2119
- say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
2407
+ say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${countDeclaredOrderingPairs(plan)} edge(s)`);
2120
2408
  say(`Local hash: ${localHash}`);
2121
2409
  for (const w of warnings)
2122
2410
  say(` [warn] ${w}`);
@@ -2128,9 +2416,11 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2128
2416
  try {
2129
2417
  const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
2130
2418
  existingRunId = state.epic_run?.epic_run_id ?? null;
2419
+ existingTicketReview = state.epic_run?.ticket_review;
2131
2420
  existingStatus = state.epic_run?.status ?? null;
2132
2421
  const existingPolicy = state.epic_run?.policy_json;
2133
2422
  existingPolicyJson = existingPolicy ?? null;
2423
+ existingNotificationSink = state.notification_sink ?? null;
2134
2424
  const existingBase = existingPolicy && typeof existingPolicy === "object"
2135
2425
  ? existingPolicy.base_branch
2136
2426
  : undefined;
@@ -2170,6 +2460,22 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2170
2460
  }
2171
2461
  }
2172
2462
  }
2463
+ // BAPI-1510 — attach-only reuse. A caller that trimmed the plan must never apply
2464
+ // it to a run that already exists, so a pre-check that found a live run returns
2465
+ // here, before the policy file, any cut, plan store, approval, or other mutation.
2466
+ // A dry run keeps its ordinary reporting. Direct setup-epic never sets the option.
2467
+ //
2468
+ // Only an ACTIVATED run is attach-only. A run still in `planning` or
2469
+ // `pending_approval` was created by an earlier setup that failed a later step
2470
+ // (cut, plan store, approval); re-running setup is how it gets finished, so it
2471
+ // takes the ordinary reuse path with the trimmed plan, guarded by the pinned cut.
2472
+ const reusedRunIsActivated = existingStatus !== null && existingStatus !== "planning" && existingStatus !== "pending_approval";
2473
+ if (existingRunId && reusedRunIsActivated && handoff?.attachOnlyOnReuse === true && !opts.dryRun) {
2474
+ runIdSink.epicRunId = existingRunId;
2475
+ runIdSink.reusedExistingRun = true;
2476
+ say(`Run: reusing ${existingRunId} (status: ${existingStatus}); existing runs are unchanged`);
2477
+ return 0;
2478
+ }
2173
2479
  // --- Read the policy file (still no network) ------------------------------
2174
2480
  // BAPI-754: parsed here so a malformed file, or a contradiction between the
2175
2481
  // file and the CLI flags, fails before credentials are resolved or any request
@@ -2240,6 +2546,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2240
2546
  policyDeclaresBranch,
2241
2547
  featureBranch,
2242
2548
  reviewPolicy: opts.reviewPolicy,
2549
+ ...(opts.verificationHandoff !== undefined ? { verificationHandoff: opts.verificationHandoff } : {}),
2243
2550
  });
2244
2551
  if (!policyGate.ok) {
2245
2552
  deps.errorLog(policyGate.message);
@@ -2252,7 +2559,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2252
2559
  // The policy gate above already composed and validated; nothing is repeated
2253
2560
  // here, so the offline path and the real path refuse the same set.
2254
2561
  say(`Epic: ${opts.epicKey}`);
2255
- say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
2562
+ say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${countDeclaredOrderingPairs(plan)} edge(s)`);
2256
2563
  // BAPI-1009: the same node-count-derived strategy an ordinary run would take,
2257
2564
  // stated without cutting a branch, reading the network, or touching git.
2258
2565
  say(`Branch: ${branchStrategyLine}`);
@@ -2336,8 +2643,21 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2336
2643
  attended: opts.attended,
2337
2644
  reviewPolicy: opts.reviewPolicy,
2338
2645
  mergeExecutionMode: declaredMergeExecutionMode,
2646
+ ...(opts.verificationHandoff !== undefined ? { verificationHandoff: opts.verificationHandoff } : {}),
2339
2647
  });
2340
- const selectedEffectivePolicyFacts = effectivePolicyFacts(existingRunId !== null ? existingPolicyJson : composedForCreate, selectedPolicySource);
2648
+ // Reuse the actual composition result; drive-epic never interprets policy files.
2649
+ let launchReadiness = null;
2650
+ if (!opts.localOnly) {
2651
+ try {
2652
+ launchReadiness = await previewLaunchReadiness(access, existingRunId !== null ? existingPolicyJson ?? {} : composedForCreate, deps.fetch);
2653
+ }
2654
+ catch {
2655
+ // An unavailable preview is not a proven launch deficiency. Create is authoritative.
2656
+ launchReadiness = null;
2657
+ }
2658
+ }
2659
+ const selectedTicketReview = ticketReviewSummary(existingTicketReview, existingRunId !== null ? existingPolicyJson ?? {} : composedForCreate);
2660
+ const selectedEffectivePolicyFacts = effectivePolicyFacts(existingRunId !== null ? existingPolicyJson : composedForCreate, selectedPolicySource, existingRunId !== null ? existingNotificationSink : undefined);
2341
2661
  say(`Policy: effective ${selectedEffectivePolicyFacts.source}; serial execution; ` +
2342
2662
  `caps ${selectedEffectivePolicyFacts.caps ? JSON.stringify(selectedEffectivePolicyFacts.caps) : "unavailable"}; ` +
2343
2663
  `timeouts ${selectedEffectivePolicyFacts.job_timeouts ? JSON.stringify(selectedEffectivePolicyFacts.job_timeouts) : "unavailable"}; ` +
@@ -2393,6 +2713,15 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2393
2713
  // dropping `--feature-branch` reuses the run only when the flag is what asked
2394
2714
  // for the branch; when the DEFAULT asked, `--into-base` is the way to say
2395
2715
  // "keep this run on base".
2716
+ // BAPI-1496 — the hand-off flags compose a NEW run's policy only. A live run's
2717
+ // policy changes through `patch-policy`; reusing it here would either drop the
2718
+ // owner's answer silently or patch a live run behind the operator's back.
2719
+ if (existingRunId && opts.verificationHandoff !== undefined) {
2720
+ deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}). --verification-handoff ` +
2721
+ "sets the policy of a NEW run only. Change a live run's verification hand-off with " +
2722
+ "`epic-implementer patch-policy`, or re-run without --verification-handoff to reuse it.");
2723
+ return 1;
2724
+ }
2396
2725
  if (existingRunId && featureBranch !== undefined && existingBaseBranch !== featureBranch) {
2397
2726
  const reuseAdvice = opts.featureBranch !== undefined
2398
2727
  ? "Re-run without --feature-branch to reuse it unchanged"
@@ -2449,7 +2778,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2449
2778
  return 1;
2450
2779
  }
2451
2780
  if (opts.reviewPolicy !== undefined) {
2452
- say(`Review: ${opts.reviewPolicy} (per-run; repository defaults pending BAPI-694)`);
2781
+ say(`Review: ${opts.reviewPolicy} (per-run only; no repository default is stored)`);
2453
2782
  }
2454
2783
  // --- Authoritative server validation (BAPI-754) --------------------------
2455
2784
  // Runs BEFORE any create. A plan the server would refuse must not cost a run
@@ -2607,7 +2936,9 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2607
2936
  say(" serialization disabled — no ordering was derived from file overlap");
2608
2937
  }
2609
2938
  else if (validated.insertedEdges === 0) {
2610
- say(" serialization enabled — no declared file overlaps produced edges");
2939
+ say(validated.overlappingPairsFound > 0
2940
+ ? ` serialization enabled — ${validated.overlappingPairsFound} overlapping pair(s) already ordered by declared dependencies`
2941
+ : " serialization enabled — no declared file overlaps produced edges");
2611
2942
  }
2612
2943
  else {
2613
2944
  say(` serialization enabled — ${validated.insertedEdges} edge(s) inserted from ` +
@@ -2658,6 +2989,23 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2658
2989
  cutCommitSha = truth.cutCommitSha;
2659
2990
  say(`Cut: ${cutCommitSha} (canonical indexed commit; origin/${effectiveFeatureBranch} will be cut here)`);
2660
2991
  }
2992
+ // BAPI-1510 — pinned cut. A trimmed launch classified children against ONE
2993
+ // indexed commit; refuse unless setup's own canonical read names that same commit.
2994
+ // This runs even when trimming left a base-branch or single-node launch that
2995
+ // would not otherwise read canonical parse truth, and before any create-side
2996
+ // effect (including on a dry run).
2997
+ if (handoff?.expectedCutCommitSha !== undefined) {
2998
+ let observedCut = cutCommitSha;
2999
+ if (observedCut === null) {
3000
+ const pinTruth = await readCanonicalParseTruth(access, deps.fetch);
3001
+ observedCut = pinTruth.ok ? pinTruth.cutCommitSha : null;
3002
+ }
3003
+ if (observedCut === null || observedCut !== handoff.expectedCutCommitSha) {
3004
+ deps.errorLog("The indexed commit could not be confirmed or changed since the already-merged check. " +
3005
+ "Re-run drive-epic. No run was created and no branch was changed.");
3006
+ return 1;
3007
+ }
3008
+ }
2661
3009
  if (opts.dryRun) {
2662
3010
  say("");
2663
3011
  say(validated
@@ -2675,7 +3023,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2675
3023
  : ` - feature branch: ${effectiveFeatureBranch} (validated server-side at approval; no request made in dry-run)`);
2676
3024
  if (needsCut && cutCommitSha !== null && scopeBaseBranch !== null) {
2677
3025
  say(` - would open a draft pull request ${effectiveFeatureBranch} → ${scopeBaseBranch} ` +
2678
- "for epic-implementer-ci / gate (BAPI-951; nothing opened in dry-run)");
3026
+ "for epic-implementer-ci / gate (nothing opened in dry-run)");
2679
3027
  }
2680
3028
  }
2681
3029
  if (opts.reviewPolicy !== undefined) {
@@ -2698,6 +3046,11 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2698
3046
  // BAPI-850: the exact commit the cut WOULD use, reported without
2699
3047
  // cutting. Present only when the dry run validated canonical parse truth.
2700
3048
  ...(cutCommitSha !== null ? { cut_commit_sha: cutCommitSha } : {}),
3049
+ // BAPI-1506: the repository base branch the epic branch is cut from
3050
+ // (the configured base, else "main"). Only present for a
3051
+ // feature-branch run; the launch preview compares the epic branch
3052
+ // against it, information only.
3053
+ ...(scopeBaseBranch !== null ? { scope_base_branch: scopeBaseBranch } : {}),
2701
3054
  // Only present when a policy was explicitly selected — the
2702
3055
  // no-policy JSON shape is unchanged.
2703
3056
  ...(opts.reviewPolicy !== undefined
@@ -2708,6 +3061,8 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2708
3061
  server_validated: validated !== null,
2709
3062
  policy_applied: "absent",
2710
3063
  effective_policy: selectedEffectivePolicyFacts,
3064
+ launch_readiness: launchReadiness,
3065
+ ticket_review: selectedTicketReview,
2711
3066
  ...(validated
2712
3067
  ? {
2713
3068
  server_plan_hash: validated.planHash,
@@ -2791,6 +3146,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2791
3146
  // BAPI-1102 — the reuse path's authoritative id, taken from the pre-check's
2792
3147
  // server response rather than from anything local.
2793
3148
  runIdSink.epicRunId = existingRunId;
3149
+ runIdSink.reusedExistingRun = true;
2794
3150
  say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
2795
3151
  // A pre-check that already found a live run means create would answer 200
2796
3152
  // anyway; reconcile the file against that run's stored policy directly.
@@ -2802,6 +3158,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2802
3158
  result,
2803
3159
  epicRunId: existingRunId,
2804
3160
  storedPolicy: existingPolicyJson,
3161
+ notificationSink: existingNotificationSink,
2805
3162
  requestedPolicy,
2806
3163
  replacePolicy: opts.replacePolicy,
2807
3164
  });
@@ -2837,7 +3194,13 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2837
3194
  try {
2838
3195
  readiness = await fetchConductorReadiness(access, deps.fetch);
2839
3196
  }
2840
- catch {
3197
+ catch (err) {
3198
+ // BAPI-1455 — the one readable refusal: a proven-incompatible local
3199
+ // schema. Its explanation is server-authored from a maintained list.
3200
+ if (isSchemaIncompatibleError(err)) {
3201
+ deps.errorLog(formatSchemaIncompatibleRefusal("setup-epic", err).join("\n"));
3202
+ return 1;
3203
+ }
2841
3204
  // Fail CLOSED, and silently as to WHY: the thrown value can carry a URL
2842
3205
  // or a response excerpt, and this refusal is rendered to a terminal. The
2843
3206
  // generic `readiness_unreadable` message says what to do about it.
@@ -2908,6 +3271,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2908
3271
  // returns the server's run for a fresh creation AND for a create-race
2909
3272
  // adoption (`created: false`), so both are captured by this one assignment.
2910
3273
  runIdSink.epicRunId = run.epic_run_id;
3274
+ runIdSink.runCreated = created;
2911
3275
  result.status = run.status;
2912
3276
  result.run_created = created;
2913
3277
  if (created) {
@@ -2927,10 +3291,15 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
2927
3291
  // index is the race backstop that keeps it so. Naming the adopted run and
2928
3292
  // its status, rather than reporting a bare reuse, is what lets an operator
2929
3293
  // decide between continuing on it and closing it.
3294
+ runIdSink.reusedExistingRun = true;
2930
3295
  say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
2931
3296
  say(` No second run was created — one epic has at most one active ` +
2932
3297
  `run. Continue on this one, or abandon it first with ` +
2933
3298
  `\`epic-implementer abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
3299
+ // BAPI-1510 — attach-only: a race-adopted run is never touched by a
3300
+ // trimmed launch (no policy replacement, cut, plan store, or approval).
3301
+ if (handoff?.attachOnlyOnReuse === true)
3302
+ return 0;
2934
3303
  if (requestedPolicy !== undefined) {
2935
3304
  const outcome = await applyReusedRunPolicy({
2936
3305
  access,
@@ -3108,6 +3477,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
3108
3477
  ...(trackedPathEvidence !== undefined ? { trackedPaths: trackedPathEvidence } : {}),
3109
3478
  }, deps.fetch);
3110
3479
  result.plan_stored = true;
3480
+ runIdSink.planStored = true;
3111
3481
  const serverHash = stored?.plan_hash;
3112
3482
  if (typeof serverHash === "string")
3113
3483
  result.plan_hash = serverHash;
@@ -3161,7 +3531,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
3161
3531
  `Turn 'Allow unattended Epic Implementer runs' back on in the repository's ` +
3162
3532
  `Setup page in the Bridge admin panel and re-run setup-epic, or ` +
3163
3533
  `deliberately convert this run to an attended posture through the ` +
3164
- `supported operator flow. If it is already on, contact Bridge support.` +
3534
+ `supported operator flow. If it is already on, email info@bridgegpt.ai.` +
3165
3535
  `\nDetail: ${errorDetail(err)}`);
3166
3536
  }
3167
3537
  else {