@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,6 +1,16 @@
1
1
  /**
2
2
  * `install-bridge conductor` — the nested conductor bootstrap phase (BAPI-679).
3
3
  *
4
+ * BAPI-1461 — CONVERGING SETUP. With the production setup seams (always
5
+ * supplied by `install-bridge.ts`), this command is a loop, not a script: it
6
+ * reads the Claude login marker, confirms the base URL, checks `gh auth
7
+ * status`, collects every readiness finding read-only, fixes only the missing
8
+ * items in order (each write surface behind its own consent), checks again —
9
+ * running the deny-enforcement probe once, there — and prints one verdict
10
+ * beside the legacy completion line. The fixed eight-phase sequence below
11
+ * remains only for a caller that supplies no setup seams. See
12
+ * `install-bridge-conductor-setup.ts` for the item model and fix planner.
13
+ *
4
14
  * Standing up Epic Conductor v2 on a fresh repository previously meant a manual
5
15
  * sequence of API calls and hand-written config, with five server-side
6
16
  * prerequisites that were invisible until an epic was already running and
@@ -47,7 +57,7 @@
47
57
  * MCP entries (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json` only,
48
58
  * never Codex or Copilot), then print the restart advisory, because
49
59
  * `ACTIVE_GROUPS` is frozen at MCP server startup.
50
- * local observability — install the managed conductor Git hooks, then report
60
+ * local observability — install the managed Git hooks, then report
51
61
  * native ledger loadability. The ledger probe is READ-ONLY, so it needs no
52
62
  * consent and runs whether hook consent was accepted or declined.
53
63
  *
@@ -111,6 +121,13 @@ import { emptyConductorGateFlags, hasExplicitGateInput, parseLocalCommandFlag, p
111
121
  import { bootstrapConductorSupervisorDefaults, classifySupervisorBootstrapFailure, fetchConductorReadiness, previewConductorSupervisorBootstrap, } from "./conductor/bridge-api-client.js";
112
122
  import { CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, conductorInstallDoctorExitCode, conductorInstallDoctorHasFatal, formatConductorInstallDoctorReport, } from "./conductor/install-doctor.js";
113
123
  import { describeNativeLedgerAvailability, } from "./conductor/doctor.js";
124
+ import { resolveGithubMergeRepositoryIdentity, } from "./credential-store.js";
125
+ import { isUsableMergeCredentialValue, resolveLocalMergeCredentialDetailed, } from "./conductor/merge-credential.js";
126
+ import { readStoredTestCommand, writeStoredTestCommand, } from "./testing-config-client.js";
127
+ import { detectTestCommand } from "./test-command-detection.js";
128
+ import { TestCommandValidationError, validateTestCommand } from "./test-command-validation.js";
129
+ import { EPIC_IMPLEMENTER_CI_CHECK_NAME } from "./epic-ci-workflow.js";
130
+ import { assessSetupIndex, catalogLabel, compareIndexedToMerge, planSetupFixes, projectSetupItems, readSetupPullRequest, renderSetupAssessment, setupVerdictLine, unmetBlockingItems, } from "./install-bridge-conductor-setup.js";
114
131
  import { CONDUCTOR_BIN_UNRESOLVED_ERROR, MANAGED_HOOK_NAMES, } from "./conductor/git-hooks.js";
115
132
  import os from "node:os";
116
133
  import path from "node:path";
@@ -119,6 +136,7 @@ import { bindEpicLaunchCredential, readStoredBinding, } from "./credential-store
119
136
  import { EPIC_LAUNCH_EXECUTION_MODES, EPIC_LAUNCH_PROFILE_NAME_PATTERN, EPIC_LAUNCH_PROFILE_VERSION, createNodeEpicLaunchStoreDeps, readEpicLaunchProfile, renderEpicLaunchCommand, resolveEpicLaunchTarget, writeEpicLaunchProfile, } from "./epic-launch-profile.js";
120
137
  import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
121
138
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
139
+ import { LOCAL_MERGE_CREDENTIAL_ENV_KEYS } from "./conductor/merge-credential.js";
122
140
  import { CLAUDE_REVIEW_WORKFLOW_RELPATH, renderClaudeReviewWorkflow, } from "./claude-review-workflow.js";
123
141
  import { formatExecutorInstallPreflight, } from "./executor/install-preflight.js";
124
142
  import { buildExecutorServiceStartPlan, formatExecutorServiceStartFailure, } from "./executor/service-lifecycle.js";
@@ -149,24 +167,48 @@ export function getInstallBridgeConductorUsage() {
149
167
  return [
150
168
  "Usage: install conductor [options]",
151
169
  "",
152
- "Bootstrap the Epic Implementer for this repository: run the unified read-only",
153
- "doctor, write the safe project-default supervisor posture, optionally scaffold",
154
- "the claude-review workflow, report the capability matrix, and re-run the doctor.",
170
+ "Set up this repository for the Epic Implementer. It checks everything first",
171
+ "(read-only), fixes only what is missing, checks again, and ends with one",
172
+ "verdict: \"Ready for your first epic\" or \"Not ready: N steps left\" with the fix",
173
+ "for each step. The last line is still complete or complete-with-manual-steps.",
174
+ "It exits 0 when ready and 1 otherwise (0 for --dry-run and for a declined URL",
175
+ "confirmation). `install-bridge conductor` is the same command.",
176
+ "",
177
+ "It first reads your Claude sign-in (~/.claude.json only) and, after the URL",
178
+ "confirmation, runs `gh auth status`. A ready repository gets no questions and no",
179
+ "writes. Otherwise it asks only what a missing item needs, each with a default of no:",
180
+ " - which repository, only when it cannot tell;",
181
+ " - saving the test command, only when none is stored;",
182
+ " - opening the setup pull request with the CI workflow;",
183
+ " - storing the repository gate and safe defaults, when they would change;",
184
+ " - storing a GitHub merge token on this computer (read with no echo).",
185
+ "The URL confirmation is asked on every run and is not one of these. Without an",
186
+ "interactive terminal every question is a decline: no token is stored and no",
187
+ "pull request is opened. When setup reports that its setup pull request is",
188
+ "waiting, merge it and run setup again: the re-run queues one repository index",
189
+ "if needed and reports it pending until it finishes.",
190
+ "",
191
+ "Merge token: setup can store a GitHub token for this repository in your user",
192
+ "credential store (~/.config/bridge/credentials.json). GITHUB_MERGE_TOKEN, then",
193
+ "BAPI_LOCAL_MERGE_TOKEN, take precedence over it. It is never sent to Bridge.",
194
+ "When GitHub rejects it, setup offers to replace it. Removing it deletes the",
195
+ "local copy only; it does not revoke the token at GitHub, and a running executor",
196
+ "keeps the token it started with until it restarts.",
155
197
  "",
156
198
  "Options:",
199
+ " --remove-merge-token Remove the merge token stored on this computer for",
200
+ " this repository (its own consent), then exit.",
157
201
  " --repo <name> Repository name (default: BAPI_REPO_NAME / .bridge/config)",
158
- " --required-check <name> A provider check for the gate (repeatable). Explicit",
159
- " owner input: stored as a confirmed provider gate and",
160
- " projected into the done gate, as before.",
202
+ " --required-check <name> A provider check for the gate (repeatable). Stored as a",
203
+ " confirmed provider gate and projected into the done gate.",
161
204
  " --required-checks a,b Comma-separated form of --required-check",
162
205
  "",
163
- "Repository gate (BAPI-1391) — what must pass before a child merges. A CONFIRMED",
164
- "gate is required: provider checks, local commands, both (mixed), or none.",
165
- "Without gate flags, a confirmed gate already stored (including an older",
166
- "--required-check setup) is used unchanged; otherwise discovery reads this",
167
- "working tree and the provider's real check history and PROPOSES a gate, which",
168
- "is stored only after you confirm or edit it at a prompt. Discovery saves",
169
- "nothing. Explicit gate flags are owner input and are stored as confirmed.",
206
+ "Repository gate — what must pass before a child merges. A CONFIRMED gate is",
207
+ "required: provider checks, local commands, both (mixed), or none. Without gate",
208
+ "flags, a confirmed gate already stored is used unchanged; otherwise discovery",
209
+ "reads this working tree and the provider's real check history and PROPOSES a",
210
+ "gate, which is stored only after you confirm or edit it at a prompt. Discovery",
211
+ "saves nothing. Explicit gate flags are stored as confirmed.",
170
212
  " --provider-check <name> An exact provider-reported check name (repeatable).",
171
213
  " Admission refuses one that has never reported.",
172
214
  " --local-command <cmd> A local command the executor runs before each merge",
@@ -184,35 +226,20 @@ export function getInstallBridgeConductorUsage() {
184
226
  " --clear-gate Clear the confirmed gate (its own consent), then exit.",
185
227
  " --qualify-gate Qualify a confirmed local or mixed gate: queue the",
186
228
  " qualification job and drain it with a short-lived",
187
- " repository-wide executor limited to that job type.",
188
- " Alone, it does only that; with gate flags it runs",
189
- " after they are stored, without the offer prompt.",
190
- " Every gate write first checks that the server advertises gate_profile.v1;",
191
- " against an older server it prints \"Bridge API update required\" and exits 1.",
229
+ " executor limited to that job type. Alone, it does only",
230
+ " that; with gate flags it runs after they are stored,",
231
+ " without the offer prompt.",
192
232
  "",
193
233
  ` --review-policy <source> One of: ${CONDUCTOR_REVIEW_POLICY_SOURCES.join(", ")}`,
194
- " Omitted, install assumes repository_policy: the",
195
- " prospective default for a new feature-branch run at",
196
- " first approval when neither the run nor the",
197
- " repository sets a policy. It scaffolds no Claude",
198
- " review workflow and stores no default at all.",
199
- " AI review is an explicit opt-in: pass",
200
- " --review-policy verdict_protocol to scaffold the",
201
- " workflow. Supplying any value EXPLICITLY also",
202
- " persists it as the supervisor project default,",
203
- " stamped into a new run that carries no review",
204
- " policy of its own.",
234
+ " Omitted, install assumes repository_policy and",
235
+ " scaffolds no Claude review workflow. AI review is an",
236
+ " explicit opt-in: pass --review-policy verdict_protocol",
237
+ " to scaffold the workflow. Supplying any value also",
238
+ " persists it as the project default, stamped into a new",
239
+ " run that carries no review policy of its own.",
205
240
  " --epic-key <KEY> Epic key for the setup-epic verification phase",
206
241
  " --plan-file <path> Plan sidecar for the setup-epic verification phase",
207
- " --executor-id <id> Stable executor id. Supplying it OPTS IN to the",
208
- " consented executor-provisioning phase below; the",
209
- " phase is skipped entirely without it.",
210
- " --epic-run-id <id> Claim scope for the generated unit (repeatable).",
211
- " --repo-wide Claim-scope opt-out: claim from every authorized",
212
- " repo, unscoped by run. EXACTLY ONE of --epic-run-id",
213
- " or --repo-wide is required with --executor-id — the",
214
- " executor refuses to start without one, so a unit",
215
- " generated without it would fail on every boot.",
242
+ " AI PR review only (--review-policy verdict_protocol):",
216
243
  " --allowed-bot <slug> claude-review workflow: allowed bot login",
217
244
  " --secret-name <NAME> claude-review workflow: OAuth token secret NAME",
218
245
  " --branches a,b claude-review workflow: PR branch triggers",
@@ -222,85 +249,50 @@ export function getInstallBridgeConductorUsage() {
222
249
  " install keeps the stored gate and only adds missing",
223
250
  " checks. Any requirement a replacement would remove",
224
251
  " is previewed and needs its own exact approval.",
225
- " --launch-profile <name> (BAPI-1260) Opt in to the reusable epic launch: after",
226
- " every mandatory prerequisite is verified, persist a",
227
- " host-local profile and print the ONE drive-epic",
228
- " command that selects it (runs in a fresh shell).",
229
- " Optional for production — drive-epic targets it with",
230
- " no profile. Needed for local or durable-host targets.",
231
- " --base-url <url> Explicit Bridge API target for --launch-profile.",
232
- " Required there (or BAPI_BASE_URL): guided setup",
233
- " approves one named server and has no default.",
234
- " --execution-mode <mode> local | durable (default: durable with",
235
- " --executor-id, otherwise local).",
236
- " --policy-file <path> Run policy the profile references. An unattended",
237
- " run escalates to a notify.webhook_url reachable",
238
- " from the API server or, with none declared, to the",
239
- " account's email (notify.local_sink is not enough).",
252
+ " --epic-branch-ci-decision <include|decline>",
253
+ " (GitHub only) Record whether child merges into an epic",
254
+ " branch wait for CI on that PR. Without it you are",
255
+ " asked; a non-interactive run records nothing.",
240
256
  " --dry-run Diagnose and preview only. Performs NO writes:",
241
257
  " no POST, no file write, no host MCP config write,",
242
258
  " no Git hook write, no install-state or capability",
243
- " write, no service-unit write, no launchctl/systemctl",
244
- " or other lifecycle command, and no write- or",
245
- " start-consent prompt.",
246
- " Still asks the base-URL confirmation below first —",
247
- " its doctor readiness GET carries the repo API key.",
248
- " --epic-branch-ci-decision <include|decline>",
249
- " (BAPI-1405, GitHub only) Record whether child merges",
250
- " into an epic branch wait for CI on that PR. Without",
251
- " it you are asked; a non-interactive run records",
252
- " nothing. Never consent to change an approval rule.",
259
+ " write, no service-unit write, and no write- or",
260
+ " start-consent prompt. Still asks the base-URL",
261
+ " confirmation first — its readiness read carries the",
262
+ " repo API key.",
253
263
  " -h, --help Show this help",
254
264
  "",
265
+ "Owner options (durable hosts and launch profiles; see `drive-epic --help-advanced`):",
266
+ " --executor-id <id> Stable executor id. Opts in to the consented",
267
+ " executor-provisioning phase.",
268
+ " --epic-run-id <id> Claim scope for the generated unit (repeatable).",
269
+ " --repo-wide Claim from every run in the repository. EXACTLY ONE of",
270
+ " --epic-run-id or --repo-wide is required with",
271
+ " --executor-id.",
272
+ " --launch-profile <name> Save a host-local launch profile and print the one",
273
+ " drive-epic command that selects it.",
274
+ " --base-url <url> Explicit Bridge API target for --launch-profile.",
275
+ " --execution-mode <mode> local | durable (default: durable with",
276
+ " --executor-id, otherwise local).",
277
+ " --policy-file <path> Run policy the profile references.",
278
+ "",
255
279
  "The resolved BAPI_BASE_URL is displayed and confirmed BEFORE any network call",
256
- "(including the read-only doctor GET — BAPI_BASE_URL silently defaults to",
257
- "production when unset). The bootstrap POST then asks its own, separate write",
258
- "consent. Without a TTY there is no consent, so the command aborts.",
280
+ "(BAPI_BASE_URL silently defaults to production when unset). The bootstrap POST",
281
+ "then asks its own, separate write consent. Without a TTY there is no consent,",
282
+ "so the command aborts.",
259
283
  "",
260
284
  "Tool visibility (its own consent): adds the `epic_implementer` token to the",
261
285
  "BRIDGE_MCP_PROFILE of the Bridge entry in host MCP configurations that",
262
- "ALREADY have one. Automatic merging covers .mcp.json, .vscode/mcp.json, and",
263
- ".cursor/mcp.json only — never Codex or Copilot, which are neither inspected",
264
- "nor changed. No file is ever created: when no supported config carries a",
265
- "a Bridge entry, the run says so and points at the base `install` command.",
266
- "The MCP server captures its active groups at startup, so a merge activates on",
267
- "the next launch — restart your MCP client to see the Epic Implementer tools.",
286
+ "ALREADY have one (.mcp.json, .vscode/mcp.json, and .cursor/mcp.json only). No",
287
+ "file is ever created: when no supported config carries a Bridge entry, the run",
288
+ "says so and points at the base `install` command. Restart your MCP client to",
289
+ "see the Epic Implementer tools.",
268
290
  "",
269
- "Local observability (a SEPARATE consent): installs or updates this",
270
- "repository's managed conductor Git hooks (post-commit,",
271
- "reference-transaction) and reports each hook's action. It then probes native",
272
- "ledger loadability, which is READ-ONLY and therefore runs with or without",
273
- "hook consent; a degraded ledger reports the module, NODE_MODULE_VERSION, and",
274
- "failure kind, and never triggers an install, rebuild, or migration.",
275
- "",
276
- "Durable-host mode: this command is the supported bring-up for a persistent,",
277
- "laptop-independent executor. It composes planning, separately consented unit",
278
- "writing, separately consented start, read-only local inspection, and bounded",
279
- "server-observed readiness. `executor install-service` still generates a unit and",
280
- "nothing else, and remains available for operators who manage lifecycle themselves.",
281
- "",
282
- "Host ownership (operator-operated or Bridge-operated) is an operational selection,",
283
- "not server policy: both run identical generation and lifecycle logic, and",
284
- "BAPI_BASE_URL is the only ownership/location input. Bridge-operated mode allocates",
285
- "no host — it configures one already assigned. A repository with no assigned durable",
286
- "host is NOT ready; escalate that to Bridge operations.",
287
- "",
288
- "With --executor-id, this command does the whole leg. A preflight checks the platform,",
289
- "`wt` / `git` / `claude` on PATH, and an explicit base URL, then reports whether",
290
- "this host has a `claude login` marker — that line is ADVISORY and never blocks,",
291
- "and no API key, token, or other credential is requested, stored, or checked.",
292
- "",
293
- "Two independent consents follow. The first writes the service unit; the second,",
294
- `asked separately, starts it (${"`launchctl bootstrap`"} on darwin,`,
295
- "`systemctl --user daemon-reload && systemctl --user enable --now` on linux) with",
296
- "exactly one attempt and no retry loop. Declining either one, or a start that",
297
- "fails, prints the exact start command and finishes at complete-with-manual-steps.",
298
- "",
299
- "After a successful start the command polls the server for roughly 90 seconds for",
300
- "the executor to be observed. Only that live server observation can report",
301
- "executor_ready: a written unit and a successful local start never can, so an",
302
- "observation timeout finishes at complete-with-manual-steps and says exactly what",
303
- "was and was not achieved.",
291
+ "Local observability (a SEPARATE consent): installs or updates this repository's",
292
+ "managed Git hooks (post-commit, reference-transaction) and reports each hook's",
293
+ "action. It then probes native ledger loadability, which is READ-ONLY; a degraded",
294
+ "ledger reports the module, NODE_MODULE_VERSION, and failure kind, and never",
295
+ "triggers an install, rebuild, or migration.",
304
296
  "",
305
297
  `See ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}.`,
306
298
  ].join("\n");
@@ -343,6 +335,7 @@ export function parseInstallBridgeConductorArgs(argv) {
343
335
  let executionMode;
344
336
  let policyFile;
345
337
  let epicBranchCiDecision;
338
+ let removeMergeToken = false;
346
339
  const takeValue = (arg, inline, next) => {
347
340
  if (inline !== undefined) {
348
341
  if (inline.trim().length === 0)
@@ -380,6 +373,12 @@ export function parseInstallBridgeConductorArgs(argv) {
380
373
  return { status: "error", message: "--repo-wide takes no value" };
381
374
  repoWide = true;
382
375
  break;
376
+ case "--remove-merge-token":
377
+ if (inline !== undefined) {
378
+ return { status: "error", message: "--remove-merge-token takes no value" };
379
+ }
380
+ removeMergeToken = true;
381
+ break;
383
382
  case "--gate-none":
384
383
  case "--confirm-gate":
385
384
  case "--clear-gate":
@@ -570,6 +569,36 @@ export function parseInstallBridgeConductorArgs(argv) {
570
569
  "--executor-id.",
571
570
  };
572
571
  }
572
+ // BAPI-1461 — token removal is its own action: only --repo and --dry-run may
573
+ // accompany it, so it can never be mistaken for, or followed by, setup writes.
574
+ if (removeMergeToken) {
575
+ const conflicting = requiredChecks.length > 0 ||
576
+ hasExplicitGateInput(gate) ||
577
+ gate.clearGate ||
578
+ gate.qualifyGate ||
579
+ executorId !== undefined ||
580
+ epicRunIds.length > 0 ||
581
+ repoWide ||
582
+ launchProfile !== undefined ||
583
+ baseUrl !== undefined ||
584
+ executionMode !== undefined ||
585
+ policyFile !== undefined ||
586
+ epicKey !== undefined ||
587
+ planFile !== undefined ||
588
+ epicBranchCiDecision !== undefined ||
589
+ reviewPolicyExplicit ||
590
+ replaceDoneGate ||
591
+ skipWorkflow ||
592
+ allowedBot !== undefined ||
593
+ secretName !== undefined ||
594
+ branches !== undefined;
595
+ if (conflicting) {
596
+ return {
597
+ status: "error",
598
+ message: "--remove-merge-token is a standalone action: combine it only with --repo or --dry-run.",
599
+ };
600
+ }
601
+ }
573
602
  // BAPI-1260 — the launch-profile inputs only mean something together.
574
603
  if (launchProfile === undefined && (baseUrl !== undefined || executionMode !== undefined)) {
575
604
  return {
@@ -610,6 +639,7 @@ export function parseInstallBridgeConductorArgs(argv) {
610
639
  : {}),
611
640
  ...(policyFile ? { policyFile } : {}),
612
641
  ...(epicBranchCiDecision ? { epicBranchCiDecision } : {}),
642
+ ...(removeMergeToken ? { removeMergeToken: true } : {}),
613
643
  },
614
644
  };
615
645
  }
@@ -706,15 +736,13 @@ const CONSENT_YES = new Set(["y", "yes"]);
706
736
  /** Fixed, value-free operator text for each bootstrap failure (BAPI-1288). */
707
737
  const BOOTSTRAP_FAILURE_TEXT = Object.freeze({
708
738
  "unsupported-protocol": "the Bridge API server does not support the gate-change preview protocol " +
709
- "(it predates BAPI-1288) or answered a response this installer cannot verify. " +
710
- "Upgrade the server before re-running; this installer never falls back to the " +
711
- "older bootstrap that replaced configured gates.",
739
+ "or answered a response this installer cannot verify. Email info@bridgegpt.ai; this " +
740
+ "installer never falls back to the older bootstrap that replaced configured gates.",
712
741
  "approval-required": "the server requires an approved preview for this change. Re-run to preview it again.",
713
742
  "stale-preview": "the project defaults changed since the preview, or the approval expired. " +
714
743
  "Re-run to preview the current state again.",
715
744
  "unknown-stored-state": "a stored project-default row cannot be read with certainty, so no change to it " +
716
- "can be evaluated. See the account-settings operator runbook " +
717
- "(unknown_stored_state) before re-running.",
745
+ "can be evaluated. Email info@bridgegpt.ai (unknown_stored_state) before re-running.",
718
746
  "non-relaxable": "the change would turn auto-merge on or drop the human merge-approval requirement, " +
719
747
  "which no gate-change approval can authorize.",
720
748
  "invalid-proposal": "the server rejected the proposed configuration as unreadable.",
@@ -793,6 +821,15 @@ async function confirm(deps, promptText) {
793
821
  const answer = await deps.promptLine(promptText);
794
822
  return CONSENT_YES.has((answer ?? "").trim().toLowerCase());
795
823
  }
824
+ /**
825
+ * BAPI-1452 — the merge-credential next step. Value-free configuration guidance
826
+ * only: it names the configuration keys and never reads, writes, migrates, or
827
+ * asks for a credential value. `GITHUB_MERGE_TOKEN` is the local merge
828
+ * credential; the machine-user credential is a distinct key, not an alias.
829
+ */
830
+ export const MERGE_CREDENTIAL_INSTALL_GUIDANCE = `set ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.local} (the local merge credential) in the environment the ` +
831
+ `executor is launched from, or ${LOCAL_MERGE_CREDENTIAL_ENV_KEYS.machine_token} for machine-user ` +
832
+ "mode; Bridge never stores either.";
796
833
  function renderCapabilityMatrix(deps, readiness) {
797
834
  const apiReady = readiness !== null;
798
835
  // BAPI-1391 — a CONFIRMED local-only or `none` gate has no CI checks by
@@ -829,6 +866,7 @@ function renderCapabilityMatrix(deps, readiness) {
829
866
  : ")") : ""));
830
867
  }
831
868
  deps.log(` executor provisioning: ${EXECUTOR_PROVISIONING_GUIDANCE}`);
869
+ deps.log(` merge credential: ${MERGE_CREDENTIAL_INSTALL_GUIDANCE}`);
832
870
  deps.log(` runbook: ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}`);
833
871
  return { apiReady, configurationReady, executorReady };
834
872
  }
@@ -1065,7 +1103,7 @@ export function formatConductorRestartAdvisory(mergedProfile) {
1065
1103
  /** Remediation printed when no supported host config carries a Bridge entry. */
1066
1104
  export const CONDUCTOR_PROFILE_EMPTY_MERGE_REMEDIATION = `run \`${PRINTED_INSTALL_COMMAND}\` to create and register the host MCP entry, ` +
1067
1105
  "then re-run `install conductor`.";
1068
- /** Remediation printed whenever the managed conductor hooks were not installed. */
1106
+ /** Remediation printed whenever the managed git hooks were not installed. */
1069
1107
  export const CONDUCTOR_HOOKS_MANUAL_REMEDIATION = "re-run `install conductor` and accept the local-observability consent, or run " +
1070
1108
  "`epic-implementer install-git-hooks` directly.";
1071
1109
  /**
@@ -1091,7 +1129,7 @@ async function runConductorToolVisibilityPhase(deps, local) {
1091
1129
  return {
1092
1130
  manualActions: [
1093
1131
  "tool visibility: re-run `install conductor` and accept the profile consent to " +
1094
- "make the conductor MCP tools visible.",
1132
+ "make the Epic Implementer MCP tools visible.",
1095
1133
  ],
1096
1134
  };
1097
1135
  }
@@ -1143,7 +1181,7 @@ async function runConductorToolVisibilityPhase(deps, local) {
1143
1181
  /**
1144
1182
  * Phase: local observability.
1145
1183
  *
1146
- * Installs the managed conductor Git hooks behind its OWN consent — never
1184
+ * Installs the managed Git hooks behind its OWN consent — never
1147
1185
  * inferred from the base-URL, bootstrap, workflow, profile-token, or executor
1148
1186
  * consents — and then always reports native ledger loadability, which is
1149
1187
  * read-only and therefore needs no consent at all.
@@ -1151,7 +1189,7 @@ async function runConductorToolVisibilityPhase(deps, local) {
1151
1189
  async function runConductorLocalObservabilityPhase(deps, local) {
1152
1190
  const manualActions = [];
1153
1191
  let fatal = false;
1154
- const consented = await confirm(deps, "Install or update this repository's managed conductor Git hooks " +
1192
+ const consented = await confirm(deps, "Install or update this repository's managed Git hooks " +
1155
1193
  "(post-commit, reference-transaction)? [y/N]: ");
1156
1194
  if (!consented) {
1157
1195
  deps.log(" declined — no Git hook and no install state were written.");
@@ -1166,7 +1204,7 @@ async function runConductorLocalObservabilityPhase(deps, local) {
1166
1204
  // Generic by design: the caught value can carry a hooks-directory path.
1167
1205
  // The phase is marked fatal but does NOT return here — the read-only
1168
1206
  // ledger report below still owes the operator its answer.
1169
- deps.errorLog(" the conductor Git hooks could not be installed.");
1207
+ deps.errorLog(" the Epic Implementer Git hooks could not be installed.");
1170
1208
  manualActions.push(`local observability: ${CONDUCTOR_HOOKS_MANUAL_REMEDIATION}`);
1171
1209
  fatal = true;
1172
1210
  }
@@ -1207,7 +1245,7 @@ async function runConductorLocalObservabilityPhase(deps, local) {
1207
1245
  if (!result.is_worktree) {
1208
1246
  deps.log(" not a git worktree, so no managed hook was installed (degraded optional capability).");
1209
1247
  manualActions.push("local observability: run `install conductor` from a git worktree to install the " +
1210
- "managed conductor hooks.");
1248
+ "managed git hooks.");
1211
1249
  }
1212
1250
  else {
1213
1251
  for (const hook of result.installed) {
@@ -1290,7 +1328,7 @@ async function runConductorLocalObservabilityPhase(deps, local) {
1290
1328
  deps.log(` ledger degraded — ${availability.module} did not load ` +
1291
1329
  `(NODE_MODULE_VERSION ${availability.nodeModulesAbi}, failure: ${availability.failureKind}).`);
1292
1330
  manualActions.push(`local observability: reinstall ${availability.module} for this Node runtime to ` +
1293
- "restore the local conductor event ledger.");
1331
+ "restore the local Epic Implementer event ledger.");
1294
1332
  }
1295
1333
  }
1296
1334
  return { manualActions, fatal };
@@ -1399,6 +1437,15 @@ function assessNotification(policy, attended, targetBaseUrl, readiness) {
1399
1437
  "delivers a verification to it at approval, and a failure there holds activation.",
1400
1438
  };
1401
1439
  }
1440
+ /** BAPI-1461 — whether a stored profile already records exactly this launch. */
1441
+ function launchProfileMatches(prior, next) {
1442
+ return (prior.baseUrl === next.baseUrl &&
1443
+ prior.repoName === next.repoName &&
1444
+ prior.executionMode === next.executionMode &&
1445
+ JSON.stringify(prior.runtime) === JSON.stringify(next.runtime) &&
1446
+ JSON.stringify(prior.policyRef ?? null) === JSON.stringify(next.policyRef ?? null) &&
1447
+ prior.credentialRef?.target === `bapi:${next.repoName}`);
1448
+ }
1402
1449
  /**
1403
1450
  * Verify every mandatory launch prerequisite at the selected target, then —
1404
1451
  * and only then — persist the profile and print the one command that selects
@@ -1422,7 +1469,7 @@ async function runLaunchProfilePhase(deps, options, inputs) {
1422
1469
  deps.log(` mode: ${mode}`);
1423
1470
  // (1) Access at THIS target — only a server answer proves the key works there.
1424
1471
  if (inputs.readiness === null) {
1425
- gaps.push("the selected target's conductor readiness could not be read with this repository's credential.");
1472
+ gaps.push("the selected target's Epic Implementer readiness could not be read with this repository's credential.");
1426
1473
  }
1427
1474
  // (2) The policy reference and what it implies.
1428
1475
  let policy = null;
@@ -1456,7 +1503,7 @@ async function runLaunchProfilePhase(deps, options, inputs) {
1456
1503
  gaps.push(consent === false
1457
1504
  ? "unattended consent is off for this repository; turn on 'Allow unattended Epic " +
1458
1505
  "Implementer runs' on the repository's Setup page in the Bridge admin panel " +
1459
- "(if it is already on, contact Bridge support) — this command never writes it."
1506
+ "(if it is already on, email info@bridgegpt.ai) — this command never writes it."
1460
1507
  : "unattended consent could not be verified at the selected target (older server or unreadable report).");
1461
1508
  }
1462
1509
  // (4) Notification, from the server's network perspective.
@@ -1535,29 +1582,41 @@ async function runLaunchProfilePhase(deps, options, inputs) {
1535
1582
  `--base-url ${inputs.baseUrl} --required-check <name> ...`);
1536
1583
  return { complete: false };
1537
1584
  }
1538
- const write = await confirm(deps, `Write epic launch profile '${name}' (references only, no secrets)? [y/N]: `);
1539
- if (!write) {
1540
- deps.log(" declined — no profile was written.");
1541
- return { complete: false };
1585
+ const policyRef = policyPath !== null ? { kind: "file", path: policyPath } : null;
1586
+ let profile;
1587
+ // BAPI-1461: an approved profile that already says exactly this is not
1588
+ // rewritten — no write consent is asked and nothing is written.
1589
+ if (existing.ok && launchProfileMatches(existing.profile, {
1590
+ baseUrl: inputs.baseUrl, repoName, executionMode: mode, runtime: inputs.runtime.runtime, policyRef,
1591
+ })) {
1592
+ deps.log(` profile '${name}' already matches — nothing written.`);
1593
+ profile = existing.profile;
1542
1594
  }
1543
- const profile = {
1544
- version: EPIC_LAUNCH_PROFILE_VERSION,
1545
- name,
1546
- baseUrl: inputs.baseUrl,
1547
- repoName,
1548
- executionMode: mode,
1549
- host: lp.hostname(),
1550
- runtime: inputs.runtime.runtime,
1551
- policyRef: policyPath !== null ? { kind: "file", path: policyPath } : null,
1552
- credentialRef: { store: "bridge-credentials", target: `bapi:${repoName}` },
1553
- approvedAt: lp.nowIso(),
1554
- };
1555
- const written = await lp.writeProfile(profile);
1556
- if (!written.ok) {
1557
- deps.errorLog(` profile write failed: ${written.message}`);
1558
- return { complete: false };
1595
+ else {
1596
+ const write = await confirm(deps, `Write epic launch profile '${name}' (references only, no secrets)? [y/N]: `);
1597
+ if (!write) {
1598
+ deps.log(" declined — no profile was written.");
1599
+ return { complete: false };
1600
+ }
1601
+ profile = {
1602
+ version: EPIC_LAUNCH_PROFILE_VERSION,
1603
+ name,
1604
+ baseUrl: inputs.baseUrl,
1605
+ repoName,
1606
+ executionMode: mode,
1607
+ host: lp.hostname(),
1608
+ runtime: inputs.runtime.runtime,
1609
+ policyRef,
1610
+ credentialRef: { store: "bridge-credentials", target: `bapi:${repoName}` },
1611
+ approvedAt: lp.nowIso(),
1612
+ };
1613
+ const written = await lp.writeProfile(profile);
1614
+ if (!written.ok) {
1615
+ deps.errorLog(` profile write failed: ${written.message}`);
1616
+ return { complete: false };
1617
+ }
1618
+ deps.log(` ${written.action} ${written.path}`);
1559
1619
  }
1560
- deps.log(` ${written.action} ${written.path}`);
1561
1620
  deps.log("");
1562
1621
  deps.log("Launch command (fresh shell; no exports needed):");
1563
1622
  deps.log(` ${renderEpicLaunchCommand(profile, {
@@ -1569,23 +1628,24 @@ async function runLaunchProfilePhase(deps, options, inputs) {
1569
1628
  .replace("'<path>'", "<path>")}`);
1570
1629
  return { complete: true };
1571
1630
  }
1572
- export async function runInstallBridgeConductorCli(argv, deps) {
1573
- const parsed = parseInstallBridgeConductorArgs(argv);
1574
- if (parsed.status === "help") {
1575
- deps.log(parsed.usage);
1576
- return 0;
1577
- }
1578
- if (parsed.status === "error") {
1579
- deps.errorLog(`Error: ${parsed.message}`);
1580
- deps.errorLog("");
1581
- deps.errorLog(getInstallBridgeConductorUsage());
1582
- return 1;
1583
- }
1584
- const options = parsed.options;
1585
- // ---- Base URL: normalized ONCE, confirmed BEFORE any network call ------
1586
- // BAPI-1260: an epic launch profile has NO production default. Its target is
1587
- // --base-url or BAPI_BASE_URL (they must agree), decided here before any
1588
- // request; the hosted default below applies to the legacy flow only.
1631
+ // ---------------------------------------------------------------------------
1632
+ // Shared steps (used by both runners)
1633
+ // ---------------------------------------------------------------------------
1634
+ /**
1635
+ * Resolve the base URL ONCE and confirm it BEFORE any network call.
1636
+ *
1637
+ * BAPI-1260: an epic launch profile has NO production default. Its target is
1638
+ * --base-url or BAPI_BASE_URL (they must agree), decided here before any
1639
+ * request; the hosted default applies to the ordinary flow only.
1640
+ *
1641
+ * This consent gates the FIRST network request of any kind — including the
1642
+ * read-only doctor readiness GET (which carries this repository's API key) and
1643
+ * the dry-run flow. The resolver silently defaults to production when
1644
+ * BAPI_BASE_URL is unset, so the operator must see and confirm the target before
1645
+ * the key is sent anywhere. No TTY means no consent means abort. The answer is
1646
+ * never persisted: every run asks again.
1647
+ */
1648
+ async function resolveAndConfirmBaseUrl(options, deps, promptText) {
1589
1649
  let baseUrl;
1590
1650
  if (options.launchProfile !== undefined) {
1591
1651
  const target = resolveEpicLaunchTarget({
@@ -1596,7 +1656,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1596
1656
  if (!target.ok) {
1597
1657
  deps.errorLog(target.message);
1598
1658
  deps.errorLog("Nothing was sent anywhere and nothing was written.");
1599
- return 1;
1659
+ return { ok: false, code: 1 };
1600
1660
  }
1601
1661
  baseUrl = target.baseUrl;
1602
1662
  }
@@ -1604,65 +1664,51 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1604
1664
  const baseUrlResult = deps.resolveBaseUrl(deps.env);
1605
1665
  if (!baseUrlResult.ok) {
1606
1666
  deps.errorLog(baseUrlResult.error);
1607
- return 1;
1667
+ return { ok: false, code: 1 };
1608
1668
  }
1609
1669
  baseUrl = baseUrlResult.value;
1610
1670
  }
1611
- // ---- Consent #1: the resolved base URL ---------------------------------
1612
- // This consent gates the FIRST network request of any kind — including the
1613
- // read-only doctor readiness GET (which carries this repository's API key)
1614
- // and the dry-run flow. The resolver silently defaults to production when
1615
- // BAPI_BASE_URL is unset, so the operator must see and confirm the target
1616
- // before the key is sent anywhere. No TTY means no consent means abort.
1617
1671
  deps.log(`Bridge API base URL: ${baseUrl}`);
1618
1672
  if (options.launchProfile !== undefined) {
1619
1673
  deps.log(` explicit target for epic launch profile '${options.launchProfile}'` +
1620
1674
  (baseUrl === DEFAULT_BAPI_BASE_URL ? " — this is the PRODUCTION Bridge API, selected deliberately." : "."));
1621
1675
  }
1622
- const baseUrlOk = await confirm(deps, "Bootstrap against this URL? [y/N]: ");
1676
+ const baseUrlOk = await confirm(deps, promptText);
1623
1677
  if (!baseUrlOk) {
1624
1678
  deps.log("Aborted — no network call (readiness GET or bootstrap POST) was attempted.");
1625
- return 0;
1679
+ return { ok: false, code: 0 };
1626
1680
  }
1627
- const accessResult = await deps.resolveAccess(options.repo, baseUrl);
1628
- const access = accessResult.ok ? accessResult.access : null;
1629
- const accessError = accessResult.ok ? undefined : accessResult.error;
1630
- // ---- BAPI-1391: the two standalone gate actions -----------------------
1631
- // `--clear-gate`, and `--qualify-gate` without gate flags, act on the stored
1632
- // gate and stop: they never bootstrap, scaffold, or provision anything else.
1633
- const gateIo = {
1681
+ return { ok: true, baseUrl };
1682
+ }
1683
+ function gateIoFor(deps) {
1684
+ return {
1634
1685
  log: deps.log,
1635
1686
  errorLog: deps.errorLog,
1636
1687
  promptLine: deps.promptLine,
1637
1688
  fetchJson: deps.fetchJson,
1638
1689
  postJson: deps.postJson,
1639
1690
  };
1691
+ }
1692
+ /**
1693
+ * BAPI-1391: the two standalone gate actions. `--clear-gate`, and
1694
+ * `--qualify-gate` without gate flags, act on the stored gate and stop: they
1695
+ * never bootstrap, scaffold, or provision anything else. Returns null when
1696
+ * neither was requested.
1697
+ */
1698
+ async function runStandaloneGateAction(options, deps, gateIo, access, accessError, baseUrl) {
1640
1699
  const qualifyOnly = options.gate.qualifyGate && !hasExplicitGateInput(options.gate);
1641
- if (options.gate.clearGate || qualifyOnly) {
1642
- if (!access) {
1643
- deps.errorLog(`Bridge API access could not be resolved: ${accessError ?? "unknown"}`);
1644
- deps.errorLog("Nothing was written.");
1645
- return 1;
1646
- }
1647
- return options.gate.clearGate
1648
- ? runClearConductorGate(gateIo, access, { dryRun: options.dryRun })
1649
- : runQualifyConductorGate(gateIo, access, deps.gate, { dryRun: options.dryRun, baseUrl });
1650
- }
1651
- // ---- Phase 1: unified doctor, BEFORE every write -----------------------
1652
- deps.log("Phase 1/8 — running the unified epic-implementer doctor (read-only)…");
1653
- const firstReport = await deps.runDoctor({
1654
- access,
1655
- accessError,
1656
- reviewPolicySource: options.reviewPolicy,
1657
- });
1658
- deps.log(formatConductorInstallDoctorReport(firstReport));
1659
- if (conductorInstallDoctorHasFatal(firstReport)) {
1660
- deps.errorLog("");
1661
- deps.errorLog("Fatal prerequisite(s) above. Aborting before any write — no bootstrap, workflow, " +
1662
- "install-state, or verification step was attempted.");
1700
+ if (!options.gate.clearGate && !qualifyOnly)
1701
+ return null;
1702
+ if (!access) {
1703
+ deps.errorLog(`Bridge API access could not be resolved: ${accessError ?? "unknown"}`);
1704
+ deps.errorLog("Nothing was written.");
1663
1705
  return 1;
1664
1706
  }
1665
- // ---- Review policy: per-run, and a repository default when explicit ----
1707
+ return options.gate.clearGate
1708
+ ? runClearConductorGate(gateIo, access, { dryRun: options.dryRun })
1709
+ : runQualifyConductorGate(gateIo, access, deps.gate, { dryRun: options.dryRun, baseUrl });
1710
+ }
1711
+ function renderReviewPolicy(deps, options) {
1666
1712
  deps.log("");
1667
1713
  deps.log(`Review policy: ${options.reviewPolicy}`);
1668
1714
  deps.log(options.reviewPolicyExplicit
@@ -1674,15 +1720,15 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1674
1720
  "Claude review workflow is scaffolded. A new feature-branch run receives " +
1675
1721
  "repository_policy at first approval when nothing else sets one — pass " +
1676
1722
  "--review-policy verdict_protocol to opt in to AI review.");
1677
- // ---- Executor preflight: AFTER the URL consent, BEFORE any mutation ----
1678
- // Placed here so an unsupported host or a missing `wt`/`git`/`claude` refuses
1679
- // before the bootstrap POST, the workflow write, or a single filesystem
1680
- // effect — provisioning a service that cannot run is worse than refusing —
1681
- // and before the dry-run return, so `--dry-run` reports the same refusal.
1723
+ }
1724
+ /**
1725
+ * The executor preflight (BAPI-779): AFTER the URL consent, BEFORE any
1726
+ * mutation, and before the dry-run return, so `--dry-run` reports the same
1727
+ * refusal. Runs only with --executor-id.
1728
+ */
1729
+ async function runExecutorPreflightPhase(options, deps, baseUrl, access) {
1682
1730
  const executor = deps.executorProvisioning;
1683
- const local = deps.localProvisioning;
1684
1731
  const executorRequested = Boolean(executor && options.executorId);
1685
- let executorPlans = null;
1686
1732
  if (executor && executorRequested) {
1687
1733
  deps.log("");
1688
1734
  deps.log("Executor provisioning — preflight (read-only)…");
@@ -1696,12 +1742,12 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1696
1742
  // credential complaint: the only auth-related output above is the
1697
1743
  // advisory login line.
1698
1744
  deps.errorLog("");
1699
- deps.errorLog("Executor provisioning refused — this host cannot run a conductor executor.");
1745
+ deps.errorLog("Executor provisioning refused — this host cannot run an Epic Implementer executor.");
1700
1746
  for (const remediation of preflight.remediations)
1701
1747
  deps.errorLog(` → ${remediation}`);
1702
1748
  deps.log("");
1703
1749
  deps.log("complete-with-manual-steps");
1704
- return 1;
1750
+ return { kind: "refused" };
1705
1751
  }
1706
1752
  // The unit serves the repository this run resolved access for. When access
1707
1753
  // could not be resolved the doctor above has already produced a fatal
@@ -1713,15 +1759,78 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1713
1759
  deps.errorLog(`Executor provisioning refused — ${planned.error}`);
1714
1760
  deps.log("");
1715
1761
  deps.log("complete-with-manual-steps");
1716
- return 1;
1762
+ return { kind: "refused" };
1717
1763
  }
1718
- executorPlans = planned.plans;
1764
+ return { kind: "ok", plans: planned.plans };
1719
1765
  }
1720
- else if (executor) {
1766
+ if (executor) {
1721
1767
  deps.log("");
1722
1768
  deps.log(" executor provisioning skipped — pass --executor-id <id> to write and start a " +
1723
1769
  "service unit for this host.");
1724
1770
  }
1771
+ return { kind: "ok", plans: null };
1772
+ }
1773
+ export async function runInstallBridgeConductorCli(argv, deps) {
1774
+ const parsed = parseInstallBridgeConductorArgs(argv);
1775
+ if (parsed.status === "help") {
1776
+ deps.log(parsed.usage);
1777
+ return 0;
1778
+ }
1779
+ if (parsed.status === "error") {
1780
+ deps.errorLog(`Error: ${parsed.message}`);
1781
+ deps.errorLog("");
1782
+ deps.errorLog(getInstallBridgeConductorUsage());
1783
+ return 1;
1784
+ }
1785
+ const options = parsed.options;
1786
+ // BAPI-1461: production supplies the setup seams, which select the converging
1787
+ // check → fix → check → verdict runner. A caller without them keeps the
1788
+ // original phase sequence below, unchanged.
1789
+ if (deps.setup !== undefined) {
1790
+ return runConvergingSetup(options, deps, deps.setup);
1791
+ }
1792
+ if (options.removeMergeToken === true) {
1793
+ deps.errorLog("Error: --remove-merge-token is unavailable here: this caller supplied no credential-store seams.");
1794
+ return 1;
1795
+ }
1796
+ const confirmed = await resolveAndConfirmBaseUrl(options, deps, "Bootstrap against this URL? [y/N]: ");
1797
+ if (!confirmed.ok)
1798
+ return confirmed.code;
1799
+ const baseUrl = confirmed.baseUrl;
1800
+ const accessResult = await deps.resolveAccess(options.repo, baseUrl);
1801
+ const access = accessResult.ok ? accessResult.access : null;
1802
+ const accessError = accessResult.ok ? undefined : accessResult.error;
1803
+ const gateIo = gateIoFor(deps);
1804
+ const standalone = await runStandaloneGateAction(options, deps, gateIo, access, accessError, baseUrl);
1805
+ if (standalone !== null)
1806
+ return standalone;
1807
+ // ---- Phase 1: unified doctor, BEFORE every write -----------------------
1808
+ deps.log("Phase 1/8 — running the unified epic-implementer doctor (read-only)…");
1809
+ const firstReport = await deps.runDoctor({
1810
+ access,
1811
+ accessError,
1812
+ reviewPolicySource: options.reviewPolicy,
1813
+ });
1814
+ deps.log(formatConductorInstallDoctorReport(firstReport));
1815
+ if (conductorInstallDoctorHasFatal(firstReport)) {
1816
+ deps.errorLog("");
1817
+ deps.errorLog("Fatal prerequisite(s) above. Aborting before any write — no bootstrap, workflow, " +
1818
+ "install-state, or verification step was attempted.");
1819
+ return 1;
1820
+ }
1821
+ // ---- Review policy: per-run, and a repository default when explicit ----
1822
+ renderReviewPolicy(deps, options);
1823
+ // ---- Executor preflight: AFTER the URL consent, BEFORE any mutation ----
1824
+ // Placed here so an unsupported host or a missing `wt`/`git`/`claude` refuses
1825
+ // before the bootstrap POST, the workflow write, or a single filesystem
1826
+ // effect — provisioning a service that cannot run is worse than refusing —
1827
+ // and before the dry-run return, so `--dry-run` reports the same refusal.
1828
+ const executor = deps.executorProvisioning;
1829
+ const local = deps.localProvisioning;
1830
+ const preflight = await runExecutorPreflightPhase(options, deps, baseUrl, access);
1831
+ if (preflight.kind === "refused")
1832
+ return 1;
1833
+ const executorPlans = preflight.plans;
1725
1834
  // ---- BAPI-1391: the repository gate, decided BEFORE any write ----------
1726
1835
  // Capability probe first (a read, after the base-URL consent), then the stored
1727
1836
  // gate, explicit gate flags, or a discovery proposal the owner confirms. Under
@@ -1793,12 +1902,12 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1793
1902
  // hook installer, the capability recorder, or the phase's ledger probe —
1794
1903
  // this branch returns before every one of them.
1795
1904
  deps.log(local
1796
- ? " Tool visibility: would ask its OWN consent before adding the `conductor` " +
1905
+ ? " Tool visibility: would ask its OWN consent before adding the `epic_implementer` " +
1797
1906
  "profile to existing .mcp.json / .vscode/mcp.json / .cursor/mcp.json entries."
1798
1907
  : " Tool visibility: skipped (this caller supplied no local-provisioning seams)");
1799
1908
  deps.log(local
1800
1909
  ? " Local observability: would ask a SEPARATE consent before installing the " +
1801
- "managed conductor Git hooks; the ledger probe is read-only."
1910
+ "managed Git hooks; the ledger probe is read-only."
1802
1911
  : " Local observability: skipped (this caller supplied no local-provisioning seams)");
1803
1912
  if (executorPlans) {
1804
1913
  deps.log(` Would write executor unit: ${executorPlans.install.unitPath}`);
@@ -1963,62 +2072,64 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1963
2072
  });
1964
2073
  if (epicBranchCi.manualAction !== null)
1965
2074
  manualActions.push(epicBranchCi.manualAction);
1966
- // ---- Phase 3: optional workflow scaffold -------------------------------
1967
- deps.log("");
1968
- deps.log("Phase 3/8 — claude-review workflow…");
1969
- if (options.skipWorkflow || options.reviewPolicy !== "verdict_protocol") {
1970
- deps.log(` skipped — the '${options.reviewPolicy}' review policy does not consume the ` +
1971
- "sticky verdict this workflow emits.");
1972
- }
1973
- else if (!options.allowedBot || !options.secretName || !options.branches) {
1974
- deps.log(" skipped — --allowed-bot, --secret-name, and --branches are required to render " +
1975
- "the workflow template.");
1976
- }
1977
- else {
1978
- const wantWorkflow = await confirm(deps, `Write ${CLAUDE_REVIEW_WORKFLOW_RELPATH}? [y/N]: `);
1979
- if (!wantWorkflow) {
1980
- deps.log(" declined — no file was written.");
2075
+ if (options.reviewPolicy === "verdict_protocol") {
2076
+ // ---- Phase 3: optional workflow scaffold -------------------------------
2077
+ deps.log("");
2078
+ deps.log("Phase 3/8 — claude-review workflow…");
2079
+ if (options.skipWorkflow) {
2080
+ deps.log(` skipped — the '${options.reviewPolicy}' review policy does not consume the ` +
2081
+ "sticky verdict this workflow emits.");
2082
+ }
2083
+ else if (!options.allowedBot || !options.secretName || !options.branches) {
2084
+ deps.log(" skipped — --allowed-bot, --secret-name, and --branches are required to render " +
2085
+ "the workflow template.");
1981
2086
  }
1982
2087
  else {
1983
- let content;
1984
- try {
1985
- content = renderClaudeReviewWorkflow({
1986
- allowedBot: options.allowedBot,
1987
- secretName: options.secretName,
1988
- branches: options.branches,
1989
- });
2088
+ const wantWorkflow = await confirm(deps, `Write ${CLAUDE_REVIEW_WORKFLOW_RELPATH}? [y/N]: `);
2089
+ if (!wantWorkflow) {
2090
+ deps.log(" declined — no file was written.");
1990
2091
  }
1991
- catch (err) {
1992
- deps.errorLog(` workflow parameters rejected: ${err instanceof Error ? err.message : "invalid"}`);
1993
- return 1;
1994
- }
1995
- let result = await deps.writeWorkflow(deps.cwd, content, {});
1996
- if (result.ok && result.outcome === "skipped") {
1997
- // A DIFFERENT file already exists. Overwriting review automation needs
1998
- // its own decision — never inferred from the earlier consents.
1999
- const overwrite = await confirm(deps, `${CLAUDE_REVIEW_WORKFLOW_RELPATH} exists and differs. Overwrite? [y/N]: `);
2000
- if (!overwrite) {
2001
- deps.log(" existing workflow left untouched.");
2092
+ else {
2093
+ let content;
2094
+ try {
2095
+ content = renderClaudeReviewWorkflow({
2096
+ allowedBot: options.allowedBot,
2097
+ secretName: options.secretName,
2098
+ branches: options.branches,
2099
+ });
2002
2100
  }
2003
- else {
2004
- result = await deps.writeWorkflow(deps.cwd, content, { overwriteConsent: true });
2101
+ catch (err) {
2102
+ deps.errorLog(` workflow parameters rejected: ${err instanceof Error ? err.message : "invalid"}`);
2103
+ return 1;
2005
2104
  }
2006
- }
2007
- if (!result.ok) {
2008
- deps.errorLog(` workflow write failed: ${result.error}`);
2009
- return 1;
2010
- }
2011
- if (result.outcome === "written") {
2012
- deps.log(` wrote ${CLAUDE_REVIEW_WORKFLOW_RELPATH}`);
2013
- const recorded = await deps.recordArtifact(deps.cwd, CLAUDE_REVIEW_WORKFLOW_RELPATH);
2014
- if (!recorded.ok) {
2015
- // Advisory: install-state is a diagnostic aid, not a correctness
2016
- // dependency, so a failure here warns rather than failing the run.
2017
- deps.errorLog(` note: install-state not updated (${recorded.error ?? "unknown"})`);
2105
+ let result = await deps.writeWorkflow(deps.cwd, content, {});
2106
+ if (result.ok && result.outcome === "skipped") {
2107
+ // A DIFFERENT file already exists. Overwriting review automation needs
2108
+ // its own decision — never inferred from the earlier consents.
2109
+ const overwrite = await confirm(deps, `${CLAUDE_REVIEW_WORKFLOW_RELPATH} exists and differs. Overwrite? [y/N]: `);
2110
+ if (!overwrite) {
2111
+ deps.log(" existing workflow left untouched.");
2112
+ }
2113
+ else {
2114
+ result = await deps.writeWorkflow(deps.cwd, content, { overwriteConsent: true });
2115
+ }
2116
+ }
2117
+ if (!result.ok) {
2118
+ deps.errorLog(` workflow write failed: ${result.error}`);
2119
+ return 1;
2120
+ }
2121
+ if (result.outcome === "written") {
2122
+ deps.log(` wrote ${CLAUDE_REVIEW_WORKFLOW_RELPATH}`);
2123
+ const recorded = await deps.recordArtifact(deps.cwd, CLAUDE_REVIEW_WORKFLOW_RELPATH);
2124
+ if (!recorded.ok) {
2125
+ // Advisory: install-state is a diagnostic aid, not a correctness
2126
+ // dependency, so a failure here warns rather than failing the run.
2127
+ deps.errorLog(` note: install-state not updated (${recorded.error ?? "unknown"})`);
2128
+ }
2129
+ }
2130
+ else if (result.outcome === "unchanged") {
2131
+ deps.log(` ${CLAUDE_REVIEW_WORKFLOW_RELPATH} already matches — nothing written.`);
2018
2132
  }
2019
- }
2020
- else if (result.outcome === "unchanged") {
2021
- deps.log(` ${CLAUDE_REVIEW_WORKFLOW_RELPATH} already matches — nothing written.`);
2022
2133
  }
2023
2134
  }
2024
2135
  }
@@ -2027,7 +2138,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
2027
2138
  // executor provisioning, and the capability report, so a run that stops at a
2028
2139
  // later phase has still closed the local gap it consented to close.
2029
2140
  deps.log("");
2030
- deps.log("Phase 4/8 — conductor tool visibility…");
2141
+ deps.log("Phase 4/8 — Epic Implementer tool visibility…");
2031
2142
  if (local) {
2032
2143
  const outcome = await runConductorToolVisibilityPhase(deps, local);
2033
2144
  manualActions.push(...outcome.manualActions);
@@ -2040,7 +2151,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
2040
2151
  "`epic_implementer` profile to your host MCP configuration.");
2041
2152
  }
2042
2153
  deps.log("");
2043
- deps.log("Phase 5/8 — conductor local observability…");
2154
+ deps.log("Phase 5/8 — local observability…");
2044
2155
  let observabilityFatal = false;
2045
2156
  if (local) {
2046
2157
  const outcome = await runConductorLocalObservabilityPhase(deps, local);
@@ -2050,7 +2161,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
2050
2161
  else {
2051
2162
  deps.log(" skipped — this caller supplied no local-provisioning seams.");
2052
2163
  manualActions.push("local observability: run the packaged `install conductor` command, or " +
2053
- "`epic-implementer install-git-hooks`, to install the managed conductor Git hooks.");
2164
+ "`epic-implementer install-git-hooks`, to install the managed Git hooks.");
2054
2165
  }
2055
2166
  // The fail-loud hook-binary refusal (a package that ships no hook binary)
2056
2167
  // exits 1 — but only AFTER the phase's read-only ledger report has been
@@ -2166,3 +2277,851 @@ export async function runInstallBridgeConductorCli(argv, deps) {
2166
2277
  deps.log(allReady ? "complete" : "complete-with-manual-steps");
2167
2278
  return conductorInstallDoctorExitCode(finalReport);
2168
2279
  }
2280
+ // ---------------------------------------------------------------------------
2281
+ // The converging setup runner (BAPI-1461)
2282
+ // ---------------------------------------------------------------------------
2283
+ /**
2284
+ * Ask for consent to one write surface. A missing prompt seam (no terminal) is
2285
+ * a decline, never a default yes, and says so in one line naming the step.
2286
+ */
2287
+ async function askConsent(deps, promptText, step) {
2288
+ if (!deps.promptLine) {
2289
+ deps.log(` skipped ${step}: there is no interactive terminal, so this counts as declined.`);
2290
+ return false;
2291
+ }
2292
+ return confirm(deps, promptText);
2293
+ }
2294
+ /**
2295
+ * Collect every independent read-only finding. A failed read becomes an
2296
+ * `unknown` finding, never an aborted collection, and nothing here writes.
2297
+ */
2298
+ async function collectSetupEvidence(deps, setup, access, readiness, inputs) {
2299
+ // Repository identity: the server's name first, the local remote second, and a
2300
+ // disagreement reads nothing.
2301
+ const identityResult = resolveGithubMergeRepositoryIdentity({
2302
+ providerRepository: readiness?.provider_repository ?? null,
2303
+ localRemote: inputs.localRemote,
2304
+ });
2305
+ const identity = identityResult.ok ? identityResult.identity : null;
2306
+ const repository = identity !== null ? `${identity.owner}/${identity.repo}` : null;
2307
+ // The stored test command, read back from the server.
2308
+ let storedCommand = null;
2309
+ let testCommand = { state: "unknown" };
2310
+ try {
2311
+ const read = await readStoredTestCommand(setup.testingConfig(access));
2312
+ if (read.ok) {
2313
+ storedCommand = read.testCommand;
2314
+ testCommand = read.testCommand === null ? { state: "missing" } : { state: "stored" };
2315
+ }
2316
+ }
2317
+ catch {
2318
+ testCommand = { state: "unknown" };
2319
+ }
2320
+ // The managed workflow, through the installer's own read-only preflight.
2321
+ let workflow = { state: "unknown" };
2322
+ if (storedCommand === null) {
2323
+ workflow = testCommand.state === "missing" ? { state: "needs_command" } : { state: "unknown" };
2324
+ }
2325
+ else if (inputs.githubSignIn === "signed_in") {
2326
+ workflow = await assessWorkflow(setup, storedCommand);
2327
+ }
2328
+ // The setup pull request and, once it merged, the index.
2329
+ let setupPullRequest = { state: "unknown" };
2330
+ let index = { state: "not_applicable" };
2331
+ if (repository !== null && inputs.githubSignIn === "signed_in") {
2332
+ setupPullRequest = await readSetupPullRequest(setup.runGh, repository);
2333
+ index = await assessSetupIndex(setupPullRequest, () => setup.fetchParseStatus(access), (mergeSha, indexedSha) => compareIndexedToMerge(setup.runGh, repository, mergeSha, indexedSha));
2334
+ }
2335
+ if (workflow.state === "installed" && setupPullRequest.state === "unknown") {
2336
+ index = { state: "not_applicable" };
2337
+ }
2338
+ const mergeCredential = await assessMergeCredential(deps, setup, readiness, identity);
2339
+ return {
2340
+ evidence: {
2341
+ readiness,
2342
+ claudeLoggedIn: inputs.claudeLoggedIn,
2343
+ githubSignIn: inputs.githubSignIn,
2344
+ repositoryIdentity: identityResult.ok ? "resolved" : identityResult.reason,
2345
+ testCommand,
2346
+ workflow,
2347
+ setupPullRequest,
2348
+ index,
2349
+ mergeCredential,
2350
+ gateChangeDeclined: inputs.gateChangeDeclined,
2351
+ advisories: inputs.advisories,
2352
+ },
2353
+ identity,
2354
+ repository,
2355
+ storedCommand,
2356
+ };
2357
+ }
2358
+ /** Classify the managed workflow with the installer's read-only dry run. */
2359
+ async function assessWorkflow(setup, testCommand) {
2360
+ let result;
2361
+ try {
2362
+ result = await setup.installCiWorkflow(testCommand, { dryRun: true, quiet: true });
2363
+ }
2364
+ catch {
2365
+ return { state: "unknown" };
2366
+ }
2367
+ switch (result.outcome) {
2368
+ case "identical":
2369
+ return { state: "installed" };
2370
+ case "existing-pr":
2371
+ return { state: "pr_open", url: result.prUrl };
2372
+ case "dry-run":
2373
+ return { state: "needs_pr" };
2374
+ case "installed-pr-opened":
2375
+ case "upgrade-pr-opened":
2376
+ // A dry run never opens one; treat an impossible answer as unknown.
2377
+ return { state: "unknown" };
2378
+ case "failed":
2379
+ if (result.reason === "worktree_not_clean" || result.reason === "not_repository_root") {
2380
+ return { state: "precondition", remedy: result.remediation };
2381
+ }
2382
+ return result.prUrl !== undefined && result.reason === "existing_pr_differs"
2383
+ ? { state: "blocked", remedy: `${result.remediation} (${result.prUrl})` }
2384
+ : { state: "blocked", remedy: result.remediation };
2385
+ }
2386
+ }
2387
+ /**
2388
+ * The merge credential, as setup's own check sees it. The order is fixed:
2389
+ * `GITHUB_MERGE_TOKEN`, `BAPI_LOCAL_MERGE_TOKEN`, then the stored entry. An
2390
+ * invalid env value is never rescued. Machine-user mode reads only its own key.
2391
+ * A stored token counts only after the bounded probe confirms GitHub accepts
2392
+ * it; an unavailable probe is uncertainty, never a rejection.
2393
+ */
2394
+ async function assessMergeCredential(deps, setup, readiness, identity) {
2395
+ const mode = readiness?.resolved_merge_execution_mode;
2396
+ if (mode === "github_actions")
2397
+ return { state: "not_applicable" };
2398
+ if (mode === "machine_token") {
2399
+ const machine = resolveLocalMergeCredentialDetailed(deps.env, "machine_token");
2400
+ if (machine.status === "resolved")
2401
+ return { state: "configured", source: "machine" };
2402
+ if (machine.status === "invalid")
2403
+ return { state: "env_invalid", sourceKey: machine.source_key };
2404
+ return { state: "machine_missing" };
2405
+ }
2406
+ const env = resolveLocalMergeCredentialDetailed(deps.env, "local");
2407
+ if (env.status === "resolved")
2408
+ return { state: "configured", source: env.via_alias ? "alias" : "env" };
2409
+ if (env.status === "invalid")
2410
+ return { state: "env_invalid", sourceKey: env.source_key };
2411
+ if (identity === null)
2412
+ return { state: "missing" };
2413
+ let stored;
2414
+ try {
2415
+ stored = await setup.readMergeToken(identity);
2416
+ }
2417
+ catch {
2418
+ return { state: "unknown" };
2419
+ }
2420
+ if (stored.status === "unavailable")
2421
+ return { state: "unknown" };
2422
+ if (stored.status === "absent")
2423
+ return { state: "missing" };
2424
+ if (!isUsableMergeCredentialValue(stored.token))
2425
+ return { state: "stored_rejected" };
2426
+ let verdict;
2427
+ try {
2428
+ verdict = await setup.probeMergeToken({
2429
+ repository: `${identity.owner}/${identity.repo}`,
2430
+ credential: { kind: "local", token: stored.token },
2431
+ });
2432
+ }
2433
+ catch {
2434
+ return { state: "unknown" };
2435
+ }
2436
+ if (verdict === "passed")
2437
+ return { state: "configured", source: "store" };
2438
+ if (verdict === "credential_invalid" || verdict === "no_access")
2439
+ return { state: "stored_rejected" };
2440
+ return { state: "unknown" };
2441
+ }
2442
+ /**
2443
+ * The doctor's store context for this run's repository. The store is read
2444
+ * through setup's own seam, so the doctor and setup's check can never read two
2445
+ * different stores, and the doctor probes a stored token the same bounded way.
2446
+ */
2447
+ function storedMergeContextFor(setup, providerRepository, localRemote) {
2448
+ return {
2449
+ allowStoredEntry: true,
2450
+ providerRepository,
2451
+ localRemote,
2452
+ readStored: (identity) => setup.readMergeToken(identity),
2453
+ probe: (args) => setup.probeMergeToken(args),
2454
+ };
2455
+ }
2456
+ /** Whether the gate or the supervisor defaults would change (no POST needed). */
2457
+ function gateOrDefaultsNeedWrite(gatePlan, readiness) {
2458
+ if (gatePlan === null)
2459
+ return false;
2460
+ if (gatePlan.write !== null)
2461
+ return true;
2462
+ if (readiness === null)
2463
+ return false;
2464
+ return !readiness.supervisor.setup_present || !readiness.supervisor.config_present;
2465
+ }
2466
+ /** Whether a bootstrap preview shows anything the apply would change. */
2467
+ function bootstrapPreviewChangesSomething(preview) {
2468
+ return (preview.approval_required ||
2469
+ preview.changed_field_names.length > 0 ||
2470
+ preview.setup_outcome !== "preserved" ||
2471
+ preview.config_outcome !== "preserved");
2472
+ }
2473
+ function bootstrapRequestFor(options, checks) {
2474
+ return {
2475
+ required_checks: checks,
2476
+ ...(options.reviewPolicyExplicit ? { review_policy: { source: options.reviewPolicy } } : {}),
2477
+ merge_approval_required: "true",
2478
+ ...(options.replaceDoneGate ? { replace_done_gate: true } : {}),
2479
+ };
2480
+ }
2481
+ /**
2482
+ * Fix: store the repository gate and the safe supervisor defaults, under ONE
2483
+ * consent for this surface (plus the separate exact approval a weakening change
2484
+ * needs). Never inferred from the setup-PR or any other consent.
2485
+ */
2486
+ async function fixGateAndDefaults(deps, options, access, gatePlan) {
2487
+ const gateIo = gateIoFor(deps);
2488
+ deps.log("");
2489
+ deps.log("Repository gate and safe defaults");
2490
+ const ok = await askConsent(deps, "Store the repository gate and the safe supervisor defaults now? [y/N]: ", "storing the repository gate and the safe supervisor defaults");
2491
+ if (!ok) {
2492
+ deps.log(" declined — nothing was written.");
2493
+ return false;
2494
+ }
2495
+ const checks = gatePlan.desired.providerChecks;
2496
+ const request = bootstrapRequestFor(options, checks);
2497
+ const gateFirst = gatePlan.write !== null && (checks.length === 0 || gatePlan.current.source === "stored");
2498
+ if (gateFirst && (await storeConductorGate(gateIo, access, gatePlan)) === null)
2499
+ return false;
2500
+ let preview;
2501
+ try {
2502
+ preview = await previewConductorSupervisorBootstrap(access, request, deps.postJson);
2503
+ }
2504
+ catch (err) {
2505
+ reportBootstrapFailure(deps, "preview", classifySupervisorBootstrapFailure(err));
2506
+ return false;
2507
+ }
2508
+ if (bootstrapPreviewChangesSomething(preview)) {
2509
+ renderBootstrapPreview(deps, preview);
2510
+ let applyRequest = request;
2511
+ if (preview.approval_required) {
2512
+ const exact = await askConsent(deps, "Approve EXACTLY the gate changes listed above? [y/N]: ", "approving the listed gate changes");
2513
+ if (!exact || !preview.approval_token) {
2514
+ deps.log(" not approved — every configured requirement is unchanged.");
2515
+ return false;
2516
+ }
2517
+ applyRequest = { ...request, approval: { approve: true, approval_token: preview.approval_token } };
2518
+ }
2519
+ let response;
2520
+ try {
2521
+ response = await bootstrapConductorSupervisorDefaults(access, applyRequest, deps.postJson);
2522
+ }
2523
+ catch (err) {
2524
+ reportBootstrapFailure(deps, "apply", classifySupervisorBootstrapFailure(err));
2525
+ return false;
2526
+ }
2527
+ if (response.setup_outcome === null || response.config_outcome === null) {
2528
+ reportBootstrapFailure(deps, "apply", "unsupported-protocol");
2529
+ return false;
2530
+ }
2531
+ renderBootstrapOutcome(deps, response);
2532
+ }
2533
+ else {
2534
+ deps.log(" the safe supervisor defaults are already in place — nothing to write.");
2535
+ }
2536
+ if (!gateFirst && gatePlan.write !== null && (await storeConductorGate(gateIo, access, gatePlan)) === null) {
2537
+ return false;
2538
+ }
2539
+ return true;
2540
+ }
2541
+ /**
2542
+ * Fix: save the repository's test command through the setup route, after one
2543
+ * contextual agreement. The command is never run here. Only the value read back
2544
+ * from the server is used afterwards.
2545
+ */
2546
+ async function fixTestCommand(deps, setup, access) {
2547
+ const config = setup.testingConfig(access);
2548
+ deps.log("");
2549
+ deps.log("Test command");
2550
+ let detection;
2551
+ try {
2552
+ detection = await detectTestCommand({ skipStoredRead: true }, { config, readFile: (relPath) => setup.readRepositoryFile(relPath) });
2553
+ }
2554
+ catch {
2555
+ detection = { status: "failed", reason: "repository_read_failed" };
2556
+ }
2557
+ let command = null;
2558
+ if (detection.status === "detected") {
2559
+ deps.log(` Proposed: \`${detection.selected}\` (found in ${detection.provenance}). It is not run now.`);
2560
+ const save = await askConsent(deps, "Save this as the repository's test command? [y/N]: ", "saving the test command");
2561
+ if (save)
2562
+ command = detection.selected;
2563
+ }
2564
+ else {
2565
+ deps.log(" No test command could be found in this repository.");
2566
+ if (!deps.promptLine) {
2567
+ deps.log(" skipped saving a test command: there is no interactive terminal, so this counts as declined.");
2568
+ }
2569
+ else {
2570
+ const typed = (await deps.promptLine("Test command that runs your tests (press Enter to skip; a command you type is saved): ")).trim();
2571
+ if (typed.length > 0)
2572
+ command = typed;
2573
+ }
2574
+ }
2575
+ if (command === null) {
2576
+ deps.log(" not saved — the test command stays a step left.");
2577
+ return null;
2578
+ }
2579
+ try {
2580
+ command = validateTestCommand(command);
2581
+ }
2582
+ catch (err) {
2583
+ const reason = err instanceof TestCommandValidationError ? err.reason : "invalid";
2584
+ deps.log(` not saved — that command cannot be stored (${reason}).`);
2585
+ return null;
2586
+ }
2587
+ const written = await writeStoredTestCommand(config, command);
2588
+ if (!written.ok) {
2589
+ deps.log(` the save did not complete (${written.reason}); reading the stored value back…`);
2590
+ }
2591
+ // Read back after a save AND after an uncertain one; never retry the write.
2592
+ const readBack = await readStoredTestCommand(config);
2593
+ if (!readBack.ok || readBack.testCommand === null) {
2594
+ deps.log(" the stored test command could not be confirmed; it stays a step left.");
2595
+ return null;
2596
+ }
2597
+ deps.log(" saved — the stored test command is now in place.");
2598
+ return readBack.testCommand;
2599
+ }
2600
+ /** Fix: open the one setup pull request through the artifact-set installer. */
2601
+ async function fixSetupPullRequest(deps, setup, testCommand, repository) {
2602
+ deps.log("");
2603
+ deps.log("CI workflow");
2604
+ let result;
2605
+ try {
2606
+ result = await setup.installCiWorkflow(testCommand, { dryRun: false }, (promptText) => askConsent(deps, promptText, "opening the setup pull request"));
2607
+ }
2608
+ catch {
2609
+ deps.log(" the setup pull request could not be prepared; re-run setup.");
2610
+ return { workflow: { state: "unknown" }, pr: null };
2611
+ }
2612
+ switch (result.outcome) {
2613
+ case "installed-pr-opened":
2614
+ case "upgrade-pr-opened":
2615
+ deps.log(` opened the setup PR: ${result.prUrl}`);
2616
+ return { workflow: { state: "pr_open", url: result.prUrl }, pr: null };
2617
+ case "existing-pr":
2618
+ deps.log(` the setup PR is already open: ${result.prUrl}`);
2619
+ return { workflow: { state: "pr_open", url: result.prUrl }, pr: null };
2620
+ case "identical":
2621
+ deps.log(" the CI workflow is already in place — nothing to change.");
2622
+ return { workflow: { state: "installed" }, pr: null };
2623
+ case "dry-run":
2624
+ return { workflow: { state: "needs_pr" }, pr: null };
2625
+ case "failed": {
2626
+ if (result.reason === "consent_declined") {
2627
+ deps.log(" declined — no branch was pushed and no pull request was opened.");
2628
+ return { workflow: { state: "needs_pr" }, pr: null };
2629
+ }
2630
+ deps.log(` the setup pull request was not opened (${result.reason}): ${result.remediation}`);
2631
+ if (result.prUrl !== undefined)
2632
+ deps.log(` pull request: ${result.prUrl}`);
2633
+ if (result.compareUrl !== undefined)
2634
+ deps.log(` open it by hand: ${result.compareUrl}`);
2635
+ // An uncertain creation is re-read, never repeated.
2636
+ const pr = repository !== null ? await readSetupPullRequest(setup.runGh, repository) : null;
2637
+ if (pr?.state === "open")
2638
+ return { workflow: { state: "pr_open", url: pr.url }, pr };
2639
+ return { workflow: { state: "blocked", remedy: result.remediation }, pr };
2640
+ }
2641
+ }
2642
+ }
2643
+ /**
2644
+ * Fix: store (or replace) the GitHub merge token for this repository, after its
2645
+ * own consent, read with no echo. Never printed, logged, committed, or sent.
2646
+ */
2647
+ async function fixMergeToken(deps, setup, identity, replacing) {
2648
+ const repository = `${identity.owner}/${identity.repo}`;
2649
+ deps.log("");
2650
+ deps.log("GitHub merge token");
2651
+ if (replacing) {
2652
+ deps.log(` GitHub rejected the merge token stored for ${repository} (expired, revoked, or without write access).`);
2653
+ }
2654
+ else {
2655
+ deps.log(` Merges need a GitHub token with write access to ${repository}. Setup can keep it in your ` +
2656
+ "user credential store on this computer, for this repository only. It is never sent to Bridge.");
2657
+ }
2658
+ const ok = await askConsent(deps, replacing
2659
+ ? `Replace the stored merge token for ${repository}? [y/N]: `
2660
+ : `Store a GitHub merge token for ${repository} on this computer? [y/N]: `, replacing ? "replacing the stored merge token" : "storing a merge token");
2661
+ if (!ok) {
2662
+ deps.log(replacing
2663
+ ? " declined — the stored token was left as it is."
2664
+ : " declined — nothing was stored. You can set GITHUB_MERGE_TOKEN instead.");
2665
+ return false;
2666
+ }
2667
+ if (!setup.promptSecret) {
2668
+ deps.log(" skipped reading the token: there is no interactive terminal. Nothing was stored.");
2669
+ return false;
2670
+ }
2671
+ let token = "";
2672
+ try {
2673
+ token = (await setup.promptSecret("GitHub merge token (input is hidden): ")).trim();
2674
+ }
2675
+ catch {
2676
+ token = "";
2677
+ }
2678
+ if (token.length === 0) {
2679
+ deps.log(" no token was entered; nothing was stored.");
2680
+ return false;
2681
+ }
2682
+ if (!isUsableMergeCredentialValue(token)) {
2683
+ deps.log(" that value cannot be a GitHub token (it has spaces or control characters); nothing was stored.");
2684
+ return false;
2685
+ }
2686
+ const result = await setup.storeMergeToken(identity, token);
2687
+ let stored = result.ok;
2688
+ if (!result.ok && result.kind === "post-rename-uncertain") {
2689
+ // Uncertain publication: read the entry back before deciding.
2690
+ const reread = await setup.readMergeToken(identity);
2691
+ stored = reread.status === "present" && reread.token === token;
2692
+ }
2693
+ token = "";
2694
+ if (!stored) {
2695
+ deps.log(` the token could not be stored (${result.ok ? "unconfirmed" : result.kind}); nothing changed.`);
2696
+ return false;
2697
+ }
2698
+ deps.log(` stored the merge token for ${repository} on this computer.`);
2699
+ if (replacing) {
2700
+ deps.log(" A run already in progress keeps the token its executor started with until that executor restarts.");
2701
+ }
2702
+ return true;
2703
+ }
2704
+ /**
2705
+ * Fix: queue ONE repository parse when the merged setup PR is not yet indexed.
2706
+ * Re-reads the parse status first and never queues while one is queued or
2707
+ * running. Concurrent runs rely on the server's coalescing.
2708
+ */
2709
+ async function fixReindex(deps, setup, access) {
2710
+ deps.log("");
2711
+ deps.log("Repository indexing");
2712
+ let status;
2713
+ try {
2714
+ status = await setup.fetchParseStatus(access);
2715
+ }
2716
+ catch {
2717
+ deps.log(" the indexing status could not be read; nothing was queued. Re-run setup later.");
2718
+ return { state: "unknown" };
2719
+ }
2720
+ if (status.status === "queued" || status.status === "in_progress") {
2721
+ deps.log(" indexing is already running; nothing new was queued.");
2722
+ return { state: "in_progress" };
2723
+ }
2724
+ try {
2725
+ await setup.triggerParse(access);
2726
+ }
2727
+ catch {
2728
+ deps.log(" indexing could not be queued right now; re-run setup later.");
2729
+ return { state: "behind" };
2730
+ }
2731
+ deps.log(" queued one repository index so it reaches the merged setup PR. It is pending until it finishes.");
2732
+ return { state: "queued_by_setup" };
2733
+ }
2734
+ /**
2735
+ * `--remove-merge-token`: remove this repository's stored merge token under the
2736
+ * store lock, behind its own consent, then stop. The repository comes from the
2737
+ * local github.com remote, so no network call is needed. Read-only on --dry-run.
2738
+ */
2739
+ async function runRemoveMergeToken(options, deps, setup) {
2740
+ const remote = await setup.readLocalGithubRemote();
2741
+ const identity = resolveGithubMergeRepositoryIdentity({ localRemote: remote });
2742
+ if (!identity.ok) {
2743
+ deps.errorLog("Could not tell which repository's token to remove: run this from a checkout whose `origin` " +
2744
+ "remote is the github.com repository. Nothing was removed.");
2745
+ return 1;
2746
+ }
2747
+ const repository = `${identity.identity.owner}/${identity.identity.repo}`;
2748
+ deps.log(`Stored GitHub merge token for ${repository}`);
2749
+ if (options.dryRun) {
2750
+ deps.log(" Dry run — would ask, then remove the token stored on this computer. Nothing was removed.");
2751
+ return 0;
2752
+ }
2753
+ const ok = await askConsent(deps, `Remove the merge token stored for ${repository} on this computer? [y/N]: `, "removing the stored merge token");
2754
+ if (!ok) {
2755
+ deps.log(" declined — nothing was removed.");
2756
+ return 0;
2757
+ }
2758
+ const result = await setup.removeMergeToken(identity.identity);
2759
+ if (!result.ok) {
2760
+ deps.errorLog(` the token could not be removed (${result.kind}); nothing changed.`);
2761
+ return 1;
2762
+ }
2763
+ deps.log(result.action === "removed"
2764
+ ? ` removed the merge token stored for ${repository}.`
2765
+ : ` no merge token was stored for ${repository}; nothing to remove.`);
2766
+ deps.log(" This deletes the local copy only; it does not revoke the token at GitHub. GITHUB_MERGE_TOKEN or " +
2767
+ "BAPI_LOCAL_MERGE_TOKEN in your environment, and a token already handed to a running executor, " +
2768
+ "still work. To cut access now, revoke the token at GitHub and stop the executor.");
2769
+ return 0;
2770
+ }
2771
+ /** Advisory, never blocking: local extras the default flow no longer asks about. */
2772
+ function localAdvisories(report) {
2773
+ const out = [];
2774
+ const section = (id) => report.sections.find((s) => s.id === id);
2775
+ const profile = section("epic-implementer-profile-token");
2776
+ if (profile !== undefined && profile.status !== "ok") {
2777
+ out.push("to see the Epic Implementer tools in your editor, add `epic_implementer` to BRIDGE_MCP_PROFILE " +
2778
+ "in your MCP configuration, then restart the editor");
2779
+ }
2780
+ const hooks = section("conductor-hook-targets");
2781
+ if (hooks !== undefined && hooks.status !== "ok") {
2782
+ out.push("for local event history, run `epic-implementer install-git-hooks`");
2783
+ }
2784
+ return out;
2785
+ }
2786
+ /**
2787
+ * The converging setup: check everything read-only, fix only what is missing
2788
+ * (each fix behind its own consent), check again, and print one verdict.
2789
+ *
2790
+ * The four pinned properties hold: base-URL consent comes before any network
2791
+ * call (every run, never persisted); --dry-run writes nothing; each write
2792
+ * surface asks for itself; and executor readiness is only ever the server's
2793
+ * observation.
2794
+ */
2795
+ async function runConvergingSetup(options, deps, setup) {
2796
+ if (options.removeMergeToken === true)
2797
+ return runRemoveMergeToken(options, deps, setup);
2798
+ // ---- First operational check: the Claude login marker (a file read only) --
2799
+ let claudeLoggedIn = null;
2800
+ try {
2801
+ claudeLoggedIn = (await setup.detectClaudeLogin()).detected;
2802
+ }
2803
+ catch {
2804
+ claudeLoggedIn = false;
2805
+ }
2806
+ // ---- Base URL, confirmed before any network call -----------------------
2807
+ const confirmed = await resolveAndConfirmBaseUrl(options, deps, "Set up against this URL? [y/N]: ");
2808
+ if (!confirmed.ok)
2809
+ return confirmed.code;
2810
+ const baseUrl = confirmed.baseUrl;
2811
+ // ---- GitHub sign-in, right after the URL consent -----------------------
2812
+ let githubSignIn = "unavailable";
2813
+ try {
2814
+ githubSignIn = await setup.checkGithubSignIn();
2815
+ }
2816
+ catch {
2817
+ githubSignIn = "unavailable";
2818
+ }
2819
+ let localRemote = null;
2820
+ try {
2821
+ localRemote = await setup.readLocalGithubRemote();
2822
+ }
2823
+ catch {
2824
+ localRemote = null;
2825
+ }
2826
+ // ---- Repository: ask only when it is genuinely ambiguous ----------------
2827
+ let accessResult = await deps.resolveAccess(options.repo, baseUrl);
2828
+ if (!accessResult.ok && options.repo === undefined && deps.promptLine) {
2829
+ const suggestion = localRemote?.split("/")[1] ?? null;
2830
+ deps.log("");
2831
+ deps.log("Which repository is this? Bridge could not tell from the environment or .bridge/config.");
2832
+ const answer = (await deps.promptLine(suggestion !== null
2833
+ ? `Bridge repository name [${suggestion}]: `
2834
+ : "Bridge repository name (press Enter to skip): ")).trim();
2835
+ const chosen = answer.length > 0 ? answer : suggestion;
2836
+ if (chosen !== null)
2837
+ accessResult = await deps.resolveAccess(chosen, baseUrl);
2838
+ }
2839
+ const access = accessResult.ok ? accessResult.access : null;
2840
+ const accessError = accessResult.ok ? undefined : accessResult.error;
2841
+ const gateIo = gateIoFor(deps);
2842
+ const standalone = await runStandaloneGateAction(options, deps, gateIo, access, accessError, baseUrl);
2843
+ if (standalone !== null)
2844
+ return standalone;
2845
+ // ---- Check 1: the read-only doctor (no deny probe yet) ------------------
2846
+ deps.log("");
2847
+ deps.log("Checking your setup (read-only)…");
2848
+ const storedContext = storedMergeContextFor(setup, null, localRemote);
2849
+ const firstReport = await deps.runDoctor({
2850
+ access,
2851
+ accessError,
2852
+ reviewPolicySource: options.reviewPolicy,
2853
+ skipDenyProbe: true,
2854
+ storedMergeCredential: storedContext,
2855
+ });
2856
+ if (conductorInstallDoctorHasFatal(firstReport) || !access) {
2857
+ deps.log(formatConductorInstallDoctorReport(firstReport));
2858
+ deps.errorLog("");
2859
+ deps.errorLog("Setup cannot continue until the problem above is fixed. Nothing was written.");
2860
+ deps.log("");
2861
+ deps.log("Not ready: 1 step left");
2862
+ deps.log("complete-with-manual-steps");
2863
+ return 1;
2864
+ }
2865
+ const preflight = await runExecutorPreflightPhase(options, deps, baseUrl, access);
2866
+ if (preflight.kind === "refused")
2867
+ return 1;
2868
+ const executorPlans = preflight.plans;
2869
+ // ---- The gate: explicit flags win; otherwise the CI check is added ------
2870
+ const gatePhase = await resolveConductorGate(gateIo, access, options.gate, deps.gate, {
2871
+ dryRun: options.dryRun,
2872
+ ...(hasExplicitGateInput(options.gate) ? {} : { ensureProviderCheck: EPIC_IMPLEMENTER_CI_CHECK_NAME }),
2873
+ });
2874
+ if (gatePhase.kind === "exit")
2875
+ return gatePhase.code;
2876
+ const gatePlan = gatePhase.kind === "plan" ? gatePhase.plan : null;
2877
+ const gateChangeDeclined = gatePhase.kind === "plan" && gatePhase.ensureDeclined === true;
2878
+ const advisories = localAdvisories(firstReport);
2879
+ const collectInputs = { claudeLoggedIn, githubSignIn, localRemote, gateChangeDeclined, advisories };
2880
+ const first = await collectSetupEvidence(deps, setup, access, firstReport.readiness, collectInputs);
2881
+ const state = {
2882
+ evidence: first.evidence,
2883
+ identity: first.identity,
2884
+ repository: first.repository,
2885
+ storedCommand: first.storedCommand,
2886
+ failedOperations: [],
2887
+ };
2888
+ const firstItems = projectSetupItems(state.evidence);
2889
+ for (const line of renderSetupAssessment(firstItems, "What setup found"))
2890
+ deps.log(line);
2891
+ const fixes = planSetupFixes(state.evidence, {
2892
+ gateOrDefaultsNeedWrite: gateOrDefaultsNeedWrite(gatePlan, firstReport.readiness),
2893
+ dryRun: options.dryRun,
2894
+ });
2895
+ // ---- Dry run: preview only, before every write and write prompt ---------
2896
+ if (options.dryRun) {
2897
+ deps.log("");
2898
+ deps.log("Dry run — nothing was written and nothing was asked.");
2899
+ if (fixes.length === 0)
2900
+ deps.log(" No fix is needed.");
2901
+ for (const fix of fixes)
2902
+ deps.log(` Would offer: ${DRY_RUN_FIX_TEXT[fix]}`);
2903
+ if (executorPlans) {
2904
+ deps.log(` Would write executor unit: ${executorPlans.install.unitPath}`);
2905
+ deps.log(` Would start it with: ${executorPlans.start.manualCommand}`);
2906
+ }
2907
+ deps.log("");
2908
+ deps.log(setupVerdictLine(firstItems));
2909
+ deps.log("complete-with-manual-steps (dry run)");
2910
+ return 0;
2911
+ }
2912
+ // ---- Fix only what is missing, once, in order ---------------------------
2913
+ if (fixes.length > 0) {
2914
+ deps.log("");
2915
+ deps.log("Fixing what is missing…");
2916
+ }
2917
+ for (const fix of fixes) {
2918
+ if (fix === "save_test_command") {
2919
+ const saved = await fixTestCommand(deps, setup, access);
2920
+ if (saved !== null) {
2921
+ state.storedCommand = saved;
2922
+ state.evidence = { ...state.evidence, testCommand: { state: "stored" } };
2923
+ }
2924
+ }
2925
+ else if (fix === "setup_pr") {
2926
+ if (state.storedCommand === null) {
2927
+ deps.log("");
2928
+ deps.log("CI workflow");
2929
+ deps.log(" skipped — it is built from the stored test command, which is not saved yet.");
2930
+ continue;
2931
+ }
2932
+ const opened = await fixSetupPullRequest(deps, setup, state.storedCommand, state.repository);
2933
+ state.evidence = { ...state.evidence, workflow: opened.workflow };
2934
+ }
2935
+ else if (fix === "gate_and_defaults" && gatePlan !== null) {
2936
+ await fixGateAndDefaults(deps, options, access, gatePlan);
2937
+ }
2938
+ else if (fix === "merge_token" && state.identity !== null) {
2939
+ await fixMergeToken(deps, setup, state.identity, state.evidence.mergeCredential.state === "stored_rejected");
2940
+ }
2941
+ else if (fix === "reindex") {
2942
+ state.evidence = { ...state.evidence, index: await fixReindex(deps, setup, access) };
2943
+ }
2944
+ }
2945
+ // ---- Explicit operations, each behind its own consent (unchanged) -------
2946
+ if (options.gate.qualifyGate && gatePlan !== null && gatePlan.desired.localCommands.length > 0) {
2947
+ await runConductorGateQualification(gateIo, access, deps.gate, {
2948
+ revision: gatePlan.current.revision,
2949
+ baseUrl,
2950
+ });
2951
+ }
2952
+ if (options.epicBranchCiDecision !== undefined) {
2953
+ const epicBranchCi = await runEpicBranchCiInspection(gateIo, access, deps.gate, {
2954
+ decisionFlag: options.epicBranchCiDecision,
2955
+ dryRun: false,
2956
+ });
2957
+ if (epicBranchCi.manualAction !== null)
2958
+ state.evidence.advisories.push(epicBranchCi.manualAction);
2959
+ }
2960
+ if (options.reviewPolicy === "verdict_protocol" && !options.skipWorkflow) {
2961
+ await runClaudeReviewWorkflowOperation(deps, options, state);
2962
+ }
2963
+ if (options.epicKey && options.planFile) {
2964
+ deps.log("");
2965
+ deps.log("Verifying the plan with `setup-epic --dry-run`…");
2966
+ const verification = await deps.runSetupEpicDryRun([
2967
+ "--dry-run",
2968
+ "--epic-key",
2969
+ options.epicKey,
2970
+ "--plan-file",
2971
+ options.planFile,
2972
+ "--review-policy",
2973
+ options.reviewPolicy,
2974
+ ...(options.repo ? ["--repo", options.repo] : []),
2975
+ ]);
2976
+ if (verification.exitCode !== 0) {
2977
+ state.failedOperations.push({
2978
+ id: "setup-epic-verification",
2979
+ label: "Plan verification",
2980
+ state: "needs_action",
2981
+ blocking: true,
2982
+ remedy: "fix the plan problems `setup-epic --dry-run` reported, then re-run setup",
2983
+ });
2984
+ }
2985
+ else {
2986
+ deps.log(" passed.");
2987
+ }
2988
+ }
2989
+ let observedReadiness = null;
2990
+ if (deps.executorProvisioning && executorPlans) {
2991
+ deps.log("");
2992
+ deps.log("Executor service");
2993
+ observedReadiness = await runExecutorProvisioningPhase(deps, deps.executorProvisioning, executorPlans, access);
2994
+ }
2995
+ // ---- Check 2: the closing assessment (deny probe runs once, here) -------
2996
+ deps.log("");
2997
+ deps.log("Checking again…");
2998
+ const finalReport = await deps.runDoctor({
2999
+ access,
3000
+ reviewPolicySource: options.reviewPolicy,
3001
+ storedMergeCredential: storedMergeContextFor(setup, firstReport.readiness?.provider_repository ?? null, localRemote),
3002
+ });
3003
+ const finalReadiness = observedReadiness ?? finalReport.readiness;
3004
+ const closing = await collectSetupEvidence(deps, setup, access, finalReadiness, {
3005
+ ...collectInputs,
3006
+ advisories: state.evidence.advisories,
3007
+ });
3008
+ // A parse this run queued is pending, whatever the first read-back says.
3009
+ if (state.evidence.index.state === "queued_by_setup" && closing.evidence.index.state !== "reached") {
3010
+ closing.evidence.index = { state: "queued_by_setup" };
3011
+ }
3012
+ const items = [...projectSetupItems(closing.evidence), ...state.failedOperations];
3013
+ if (deniesEnforcementUnknown(finalReport)) {
3014
+ items.push({
3015
+ id: "conductor.deny-enforcement",
3016
+ label: catalogLabel("conductor.deny-enforcement"),
3017
+ state: "unknown",
3018
+ blocking: false,
3019
+ remedy: "the worker safety check could not run; run `epic-implementer doctor` to see why",
3020
+ });
3021
+ }
3022
+ // ---- BAPI-1260: the reusable launch profile (explicit) ------------------
3023
+ if (options.launchProfile !== undefined) {
3024
+ const launch = await runLaunchProfilePhase(deps, options, {
3025
+ access,
3026
+ baseUrl,
3027
+ readiness: finalReadiness,
3028
+ actorObserved: observedReadiness !== null || finalReadiness?.executor.ready === true,
3029
+ runtime: resolveLaunchRuntime(deps.executorProvisioning, options.executionMode ?? "local"),
3030
+ });
3031
+ if (!launch.complete) {
3032
+ items.push({
3033
+ id: "launch-profile",
3034
+ label: `Launch profile '${options.launchProfile}'`,
3035
+ state: "needs_action",
3036
+ blocking: true,
3037
+ remedy: "finish the launch-profile steps listed above, then re-run setup with --launch-profile",
3038
+ });
3039
+ }
3040
+ }
3041
+ for (const line of renderSetupAssessment(items, "Where things stand"))
3042
+ deps.log(line);
3043
+ for (const advisory of closing.evidence.advisories)
3044
+ deps.log(` - ${advisory}`);
3045
+ deps.log("");
3046
+ const ready = unmetBlockingItems(items).length === 0;
3047
+ deps.log(setupVerdictLine(items));
3048
+ deps.log(ready ? "complete" : "complete-with-manual-steps");
3049
+ return ready ? conductorInstallDoctorExitCode(finalReport) : 1;
3050
+ }
3051
+ /** The deny probe could not answer: an advisory, never readiness. */
3052
+ function deniesEnforcementUnknown(report) {
3053
+ const deny = report.legacyConductor?.deny_enforcement;
3054
+ return deny !== undefined && deny !== null && deny.enforced !== true;
3055
+ }
3056
+ /** Short previews of each fix for --dry-run. */
3057
+ const DRY_RUN_FIX_TEXT = Object.freeze({
3058
+ save_test_command: "save the repository's test command (one question, then PUT /setup/testing-config)",
3059
+ setup_pr: "open the setup pull request with the CI workflow (its own consent)",
3060
+ gate_and_defaults: "store the repository gate and the safe supervisor defaults (its own consent)",
3061
+ merge_token: "store a GitHub merge token on this computer (its own consent, read with no echo)",
3062
+ reindex: "queue one repository index so it reaches the merged setup PR",
3063
+ });
3064
+ /**
3065
+ * The claude-review workflow, only for an explicit `--review-policy
3066
+ * verdict_protocol`: its own write consent and a separate overwrite consent.
3067
+ */
3068
+ async function runClaudeReviewWorkflowOperation(deps, options, state) {
3069
+ deps.log("");
3070
+ deps.log("Claude review workflow");
3071
+ if (!options.allowedBot || !options.secretName || !options.branches) {
3072
+ deps.log(" skipped — --allowed-bot, --secret-name, and --branches are required to write it.");
3073
+ return;
3074
+ }
3075
+ const want = await askConsent(deps, `Write ${CLAUDE_REVIEW_WORKFLOW_RELPATH}? [y/N]: `, "writing the review workflow");
3076
+ if (!want) {
3077
+ deps.log(" declined — no file was written.");
3078
+ return;
3079
+ }
3080
+ let content;
3081
+ try {
3082
+ content = renderClaudeReviewWorkflow({
3083
+ allowedBot: options.allowedBot,
3084
+ secretName: options.secretName,
3085
+ branches: options.branches,
3086
+ });
3087
+ }
3088
+ catch (err) {
3089
+ deps.errorLog(` workflow parameters rejected: ${err instanceof Error ? err.message : "invalid"}`);
3090
+ state.failedOperations.push({
3091
+ id: "claude-review-workflow",
3092
+ label: "Claude review workflow",
3093
+ state: "needs_action",
3094
+ blocking: true,
3095
+ remedy: "fix --allowed-bot, --secret-name, or --branches, then re-run setup",
3096
+ });
3097
+ return;
3098
+ }
3099
+ let result = await deps.writeWorkflow(deps.cwd, content, {});
3100
+ if (result.ok && result.outcome === "skipped") {
3101
+ const overwrite = await askConsent(deps, `${CLAUDE_REVIEW_WORKFLOW_RELPATH} exists and differs. Overwrite? [y/N]: `, "overwriting the review workflow");
3102
+ if (overwrite)
3103
+ result = await deps.writeWorkflow(deps.cwd, content, { overwriteConsent: true });
3104
+ else
3105
+ deps.log(" existing workflow left untouched.");
3106
+ }
3107
+ if (!result.ok) {
3108
+ deps.errorLog(` workflow write failed: ${result.error}`);
3109
+ state.failedOperations.push({
3110
+ id: "claude-review-workflow",
3111
+ label: "Claude review workflow",
3112
+ state: "needs_action",
3113
+ blocking: true,
3114
+ remedy: "re-run setup to write the review workflow",
3115
+ });
3116
+ return;
3117
+ }
3118
+ if (result.outcome === "written") {
3119
+ deps.log(` wrote ${CLAUDE_REVIEW_WORKFLOW_RELPATH}`);
3120
+ const recorded = await deps.recordArtifact(deps.cwd, CLAUDE_REVIEW_WORKFLOW_RELPATH);
3121
+ if (!recorded.ok)
3122
+ deps.errorLog(` note: install-state not updated (${recorded.error ?? "unknown"})`);
3123
+ }
3124
+ else if (result.outcome === "unchanged") {
3125
+ deps.log(` ${CLAUDE_REVIEW_WORKFLOW_RELPATH} already matches — nothing written.`);
3126
+ }
3127
+ }