@bridge_gpt/mcp-server 0.2.68 → 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 (166) hide show
  1. package/README.md +226 -320
  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 +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -1,5 +1,5 @@
1
1
  import { validateBootstrapDisplayName, validateBootstrapServerIdentity } from "./bootstrap-project-identity.js";
2
- import { lookupModernBootstrapRecord, mutateModernBootstrapRecord, prepareModernBootstrapInvite, prepareModernBootstrapSignup, transitionModernBootstrapSignup, lookupLegacyBootstrapInvite, checkpointModernBootstrapIdentity, promoteModernBootstrapCredential } from "./credential-store.js";
2
+ import { BRIDGE_SUPPORT_EMAIL, archiveAndReplaceModernBootstrapSignup, archiveExpiredLegacySignupAndInstallModern, lookupModernBootstrapRecord, mutateModernBootstrapRecord, prepareModernBootstrapInvite, prepareModernBootstrapSignup, transitionModernBootstrapSignup, lookupLegacyBootstrapInvite, checkpointModernBootstrapIdentity, promoteModernBootstrapCredential } from "./credential-store.js";
3
3
  import { collectFirstBuildAdvisory, formatFirstBuildAdvisory } from "./first-build-advisory.js";
4
4
  /**
5
5
  * install-bridge — the one-command Bridge API project bootstrap (BAPI-429).
@@ -67,9 +67,9 @@ import { collectFirstBuildAdvisory, formatFirstBuildAdvisory } from "./first-bui
67
67
  * (BAPI-606) redeems a pre-issued invite; `self-serve` (BAPI-618) mints one from an
68
68
  * email. Both then feed the SAME redemption protocol. Selection is pure and
69
69
  * deterministic from flags/env, except that a BARE interactive run is asked ONE
70
- * question (BAPI-626/708): `Welcome to Bridge - do you have a token yet?`, answered
71
- * `1` (yes) or `2` (no). Answering `2` is the self-serve route, which a first-time
72
- * user could not otherwise discover. Answering `1` opens a single hidden,
70
+ * question (BAPI-626/708/1448): `Email (or press Enter if you already have a
71
+ * token): `. A valid email is the self-serve route, which a first-time user could
72
+ * not otherwise discover. A blank answer opens a single hidden,
73
73
  * CREDENTIAL-AGNOSTIC token prompt and the route is then inferred from the pasted
74
74
  * value's SHAPE — the user is never asked to pre-classify their own credential,
75
75
  * because the shape checks below do that with no probe request. Either need-key
@@ -145,6 +145,14 @@ import { spawn } from "child_process";
145
145
  import { randomBytes as cryptoRandomBytes, createHash } from "crypto";
146
146
  import os from "os";
147
147
  import path from "path";
148
+ import { detectClaudeLogin } from "./claude-login.js";
149
+ import { checkGithubSignIn } from "./github-sign-in.js";
150
+ import { readLocalGithubRemote } from "./conductor/stored-merge-credential.js";
151
+ import { probeStoredMergeToken } from "./conductor/stored-merge-token-probe.js";
152
+ import { readGithubMergeCredential, removeGithubMergeCredential, upsertGithubMergeCredential, } from "./credential-store.js";
153
+ import { createBridgeApiUrls } from "./bridge-api-urls.js";
154
+ import { installEpicCiWorkflow } from "./epic-ci-workflow-install.js";
155
+ import { fetchParseStatus, triggerRepositoryParse, } from "./conductor/bridge-api-client.js";
148
156
  import readline from "readline";
149
157
  import { runInit, InitPreflightError, buildBridgeApiEntry, buildBridgeConfigManifest, mergeBridgeApiProfileToken, resolveInitScaffoldAssets, refreshBridgeApiPackageSpec, currentBridgePackageSpec, classifyTrackedProbeExitCode, decideExistingProjectRoot, formatUndeterminedRootReplacedWarning, } from "./init.js";
150
158
  import { hasProjectRootMarker as sharedHasProjectRootMarker } from "./project-root.js";
@@ -155,7 +163,7 @@ import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, agentForPlatform
155
163
  import { provisionHostTarget, createDefaultVendorProcessDeps, inspectBridgeApiProfileToken, } from "./mcp-host-config.js";
156
164
  import { adaptBridgeEntryForHostTarget, } from "./mcp-host-entry-adapter.js";
157
165
  import { MCP_SERVER_NAME, MCP_PACKAGE_NAME, resolveRegistrationKey, } from "./mcp-identity.js";
158
- import { DUPLICATE_REGISTRATION_GUIDANCE } from "./launcher-config-inspection.js";
166
+ import { DUPLICATE_REGISTRATION_GUIDANCE, discoverClaudeCodeEffectivePin } from "./launcher-config-inspection.js";
159
167
  import { codexHome, configureBridgeCodexProfile, inspectBridgeCodexProfile } from "./codex-bridge-profile.js";
160
168
  import { recordInstalledConductorCapability, recordInstalledExecutorServiceUnit, recordInstalledProjectArtifact, writeMcpInstallState, } from "./mcp-install-state.js";
161
169
  // BAPI-1207: opt-in cross-platform agent notes (AGENTS.md / CLAUDE.md).
@@ -193,7 +201,7 @@ import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
193
201
  import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, prepareSelfServeBootstrapChallenge as prepareSelfServeBootstrapChallengeCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
194
202
  // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
195
203
  // API primitives verbatim — no duplicated polling, browser, or picker logic here.
196
- import { fetchGithubConfigurationState } from "./connect-github-api.js";
204
+ import { fetchGithubConfigurationDetails, fetchGithubConfigurationState, } from "./connect-github-api.js";
197
205
  import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
198
206
  import { fetchBitbucketConfigurationState } from "./connect-bitbucket-api.js";
199
207
  import { createDefaultConnectBitbucketDeps, runBitbucketConnectionFlow } from "./connect-bitbucket.js";
@@ -274,7 +282,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
274
282
  "'Needs setup' — and each item in them carries display_name, description, how_to_use, and (in the " +
275
283
  "'Needs setup' section only) availability_text. Render each item's display_name, its description, " +
276
284
  "and its how_to_use verbatim when that value is a non-empty string; render availability_text " +
277
- "verbatim as the setup guidance for a 'Needs setup' item, and show NO availability caveat, " +
285
+ "verbatim as the setup guidance for a 'Needs setup' item — except when that section carries a non-empty string heading: print it verbatim as the section's sub-heading instead of its name and print NO item's availability_text; with no usable heading, render each availability_text as usual — and show NO availability caveat, " +
278
286
  "asterisk, or footnote on an 'Available now' item. An item's `kind` ('tool' or 'workflow') is " +
279
287
  "contract metadata only — render both kinds identically, with no label, icon, or separate list, " +
280
288
  "and never branch on it. Keep an empty section visible with a short neutral line rather than " +
@@ -303,7 +311,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
303
311
  "short, plain-language sentence covering what is happening and why it matters to the person running " +
304
312
  "this install, rather than in internal shorthand. Narration you compose yourself must never name an " +
305
313
  "internal stage number, this command's contract version, a raw internal MCP tool identifier (like " +
306
- "get_install_manifest, apply_install_manifest, or invite_member), or a manifest field key (like " +
314
+ "get_install_manifest or apply_install_manifest), or a manifest field key (like " +
307
315
  "command_contract_version, next_step, or configured) — describe what is actually happening instead. " +
308
316
  "This restriction is scoped to narration you author: it does not apply to a user-invocable command " +
309
317
  "name you must give the human verbatim (/install-bridge, /learn-repository, connect-github, " +
@@ -317,11 +325,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
317
325
  "duplicate. This silence applies ONLY to success and ONLY in the install-spawn context: a Stage 6 " +
318
326
  "FAILURE is still reported loudly and in full, and in JOIN MODE or a direct/manual /install-bridge " +
319
327
  "invocation Stage 6 reports its success exactly as it does today. " +
320
- "The install-spawn skip set is exactly Stage 8 and Stage 9. The command-owned Stage 11 " +
321
- "(invite teammates) is NOT in that skip set: it is independently gated and may run only when the " +
322
- "caller's role is admin AND customer_type is b2b AND an interactive response is available; " +
323
- "otherwise it is skipped silently. If Stage 11 mints a teammate key, show that plaintext key " +
324
- "exactly once and never repeat it in any summary, retry, or diagnostic. " +
328
+ "The install-spawn skip set is exactly Stage 8 and Stage 9. " +
325
329
  "Do NOT ask any indexing question, call parse_repository, mention /parse-repository, or claim " +
326
330
  "indexing has started or is pending — indexing is decided entirely by the server-side readiness " +
327
331
  "funnel with no visibility from this session, so say nothing about it at all. " +
@@ -394,9 +398,8 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
394
398
  "the next step the server returned — when it returns one.",
395
399
  "If the project is already configured it instead",
396
400
  "joins you to it without proposing or applying any changes and just shows the",
397
- "concise capability report plus that same conditional next step (a b2b admin is",
398
- "additionally offered a teammate-invite",
399
- "step). So not every run applies config fields. Indexing is never asked about —",
401
+ "concise capability report plus that same conditional next step.",
402
+ "So not every run applies config fields. Indexing is never asked about —",
400
403
  "it starts automatically once the repository reaches full parse readiness.",
401
404
  "",
402
405
  "Your API key is written to a project MCP config only when that file is safe: a",
@@ -406,17 +409,15 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
406
409
  "resolves your key from the credential store at runtime. A config file that",
407
410
  "cannot be parsed is left untouched, with manual-merge instructions printed.",
408
411
  "",
409
- "Run it bare — `install` with no flags — in a terminal and it asks",
410
- `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT}\` first, with two numbered choices:`,
411
- ...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
412
- "There is NO default — pressing Enter selects nothing; you must type 1 or 2 (one",
413
- "blank or invalid answer re-prompts once, then exits with guidance). Answering 1",
414
- "prompts for an email and creates a brand-new Bridge workspace for you (the",
415
- "self-serve flow). Answering 2 opens a single hidden token prompt: paste whatever",
416
- "you were given and its SHAPE picks the route — a bootstrap invite (bapi_inv_…)",
417
- "is redeemed to CREATE a new project and mint your first admin key, while a",
418
- "Bridge API key connects to the project that already exists. You are",
419
- "never asked which kind of token you hold. That question is asked ONLY for a",
412
+ "Run it bare — `install` with no flags — in a terminal and it asks one question:",
413
+ `\`${INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT.trim()}\``,
414
+ "Type your email to create a brand-new Bridge workspace for you (the self-serve",
415
+ "flow). Press Enter if you already have a token: a single hidden token prompt",
416
+ "opens, and whatever you paste has its SHAPE pick the route — a bootstrap invite",
417
+ "(bapi_inv_…) is redeemed to CREATE a new project and mint your first admin key,",
418
+ "while a Bridge API key connects to the project that already exists. One",
419
+ "malformed answer re-prompts once, then exits with guidance. You are",
420
+ "never asked which kind of token you hold. The question is asked ONLY for a",
420
421
  "bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without an interactive",
421
422
  "terminal keeps the existing deterministic behavior and no prompt.",
422
423
  "",
@@ -532,9 +533,11 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
532
533
  " --tools claude-code,codex are accepted. On an",
533
534
  " interactive terminal WITHOUT this flag you get a",
534
535
  " numbered picker: enter one or more tool numbers,",
535
- " comma-separated (e.g. 1,3), and press Enter once —",
536
- " no tool is pre-selected (not even Claude Code) and",
537
- " you must select at least one. A non-interactive",
536
+ " comma-separated (e.g. 1,3), and press Enter once.",
537
+ " Tools Bridge detects on this machine are pre-ticked:",
538
+ " a bare Enter accepts them, and typed numbers",
539
+ " replace them. With none detected you must select",
540
+ " at least one. A non-interactive",
538
541
  " (non-TTY) run without",
539
542
  " --tools writes the legacy automatic set (Claude",
540
543
  " Code plus any detected Cursor / Copilot VS Code)",
@@ -563,6 +566,8 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
563
566
  " session — it always wins, and there is NO Claude",
564
567
  " default. Without it the launch tool is derived",
565
568
  " from your selection: Claude Code opens `claude`.",
569
+ " With more than one launchable tool selected, the",
570
+ " first one is used and no question is asked.",
566
571
  " Cursor is NOT auto-opened (its cursor-agent CLI",
567
572
  " first-run workspace-trust prompt collapses a spawned",
568
573
  " session); selecting Cursor writes .cursor/mcp.json",
@@ -1147,12 +1152,6 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1147
1152
  `Bitbucket later with 'npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo ${repoName}'.`);
1148
1153
  }
1149
1154
  }
1150
- /**
1151
- * The exact agent-notes consent-prompt prefix, shared with tests so the copy
1152
- * cannot drift. Mirrors {@link INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX}: the
1153
- * prefix is pinned, the platform tail varies with the selection.
1154
- */
1155
- export const INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX = "Write guidance so ";
1156
1155
  /**
1157
1156
  * Describe the selected notes platforms for the offer copy: a naturally joined
1158
1157
  * subject and whether it is plural (so a verb can agree). `platformLabels` are
@@ -1175,68 +1174,30 @@ function joinLabelsWithAnd(labels) {
1175
1174
  return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1176
1175
  }
1177
1176
  /**
1178
- * The default-Yes agent-notes consent prompt (BAPI-1435; payoff-led, platform-named
1179
- * copy per the BAPI-1239 install-issue #4 follow-up). Leads with WHY — teaching
1180
- * the agent how to use Bridge's MCP tools — and NAMES the selected platform(s)
1181
- * rather than the old generic "cross-platform" phrasing.
1182
- */
1183
- export function buildAgentNotesPrompt(platformLabels) {
1184
- const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1185
- return `${INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX}${subject} know${plural ? "" : "s"} how to use the Bridge MCP? Enter adds guidance for ${subject}. [Y/n]: `;
1186
- }
1187
- /**
1188
- * The offer-context lines printed immediately before the agent-notes prompt
1189
- * (BAPI-1207). Leads with the payoff — teaching the named platform(s) WHEN to
1190
- * reach for Bridge's MCP tools — then states the marker-scoped, non-destructive
1191
- * contract as a secondary line, mirroring the GitHub/Bitbucket offers' "explain
1192
- * before asking" convention.
1193
- */
1194
- export function buildAgentNotesOfferContext(targets, platformLabels) {
1195
- const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1196
- return [
1197
- "",
1198
- `Step 4c — optional: Bridge MCP guidance for ${subject}.`,
1199
- ` Write guidance so ${subject} know${plural ? "" : "s"} when to reach for Bridge's MCP tools`,
1200
- " during a task — for project standards, a second opinion, a council, or a ticket review.",
1201
- ` It writes a Bridge-owned region to ${targets.join(" and ")}; only that marked region is`,
1202
- " created or refreshed — any existing content, inside or outside it, is otherwise preserved.",
1203
- ];
1204
- }
1205
- function agentGuidancePromptConsents(result) {
1206
- if (result?.kind !== "answer" || typeof result.value !== "string")
1207
- return false;
1208
- const value = result.value.trim().toLowerCase();
1209
- return value === "" || value === "y" || value === "yes";
1210
- }
1211
- /**
1212
- * Offer to create or refresh the cross-platform agent notes, if the selected
1213
- * platforms resolve to at least one writable target (BAPI-1207).
1177
+ * Decide whether to create or refresh the cross-platform agent notes, if the
1178
+ * selected platforms resolve to at least one writable target (BAPI-1207).
1179
+ *
1180
+ * BAPI-1448: an interactive run consents by default — the question is gone, with
1181
+ * the product sign-off recorded in the install notes. The write is still bounded to
1182
+ * Bridge's marked region (see {@link writeAgentNotes}) and reported afterwards with
1183
+ * a removal hint. A non-interactive run keeps `headless-skip`: it never writes
1184
+ * guidance on its own.
1214
1185
  *
1215
- * Entirely best-effort and non-destructive, mirroring
1216
- * {@link offerGithubConnection}: by the time this runs the install itself is
1217
- * already complete and durable, so nothing here may fail the run. This
1218
- * function only resolves the CONSENT decision — the caller performs the
1219
- * actual write via {@link writeAgentNotes}, so a `--dry-run` (which never
1220
- * reaches this call) and the live path share exactly one write path.
1186
+ * Entirely best-effort and non-destructive: by the time this runs the install
1187
+ * itself is already complete and durable, so nothing here may fail the run. This
1188
+ * function only resolves the CONSENT decision — the caller performs the actual
1189
+ * write via {@link writeAgentNotes}, so a `--dry-run` (which never reaches this
1190
+ * call) and the live path share exactly one write path.
1221
1191
  */
1222
- export async function offerAgentGuidanceNotes(deps, log, targets, platformLabels) {
1192
+ export function offerAgentGuidanceNotes(deps, targets) {
1223
1193
  if (targets.length === 0)
1224
1194
  return { kind: "not-applicable" };
1225
- // No prompt surface → no offer. Never assume consent on a non-interactive run.
1226
- if (!deps.isTTY || !deps.promptGuidanceLine)
1195
+ if (!deps.isTTY)
1227
1196
  return { kind: "headless-skip" };
1228
- try {
1229
- for (const line of buildAgentNotesOfferContext(targets, platformLabels))
1230
- log(line);
1231
- const answer = await deps.promptGuidanceLine(buildAgentNotesPrompt(platformLabels));
1232
- return agentGuidancePromptConsents(answer) ? { kind: "consented" } : { kind: "declined" };
1233
- }
1234
- catch {
1235
- // The install is already durable; a failure here is never fatal to it.
1236
- deps.errorLog("Agent guidance skipped: the prompt could not be read; no guidance files were changed.");
1237
- return { kind: "prompt-error" };
1238
- }
1197
+ return { kind: "consented" };
1239
1198
  }
1199
+ /** The one-line removal hint printed after guidance was actually written (BAPI-1448). */
1200
+ export const INSTALL_BRIDGE_AGENT_NOTES_REMOVAL_HINT = "To remove it, delete the lines from <!-- BEGIN BRIDGE MCP GUIDANCE --> to <!-- END BRIDGE MCP GUIDANCE -->.";
1240
1201
  /**
1241
1202
  * One secret-free summary line for the final install output (BAPI-1207). Never
1242
1203
  * reveals guidance content, file bytes, or a credential — only which files
@@ -1273,6 +1234,47 @@ export function buildAgentNotesSummaryLine(offer, result, writeFailed, unsupport
1273
1234
  parts.push(`failed for ${failed.join(", ")}`);
1274
1235
  return `Agent notes: ${parts.join("; ")}${unsupportedNote}.`;
1275
1236
  }
1237
+ const OPTIMIZATION_POSTURE_SUMMARY_LABELS = {
1238
+ quality: "Quality",
1239
+ speed: "Speed",
1240
+ maximize_subscription: "Token Efficiency",
1241
+ };
1242
+ /**
1243
+ * The aligned "defaults Bridge chose" block printed once, right before the launch
1244
+ * consent question. Pure: it formats what the run already knows and performs no I/O.
1245
+ *
1246
+ * Layout: a header, then two rows of two cells. Both rows are indented two spaces and
1247
+ * the left cells are padded to the longer left cell plus three spaces, so the right
1248
+ * cells line up. The Ticket keys cell is omitted entirely when the server supplied no
1249
+ * valid prefix; the prefix is printed as given and never derived locally.
1250
+ */
1251
+ export function buildInstallBridgeDefaultsSummary(input) {
1252
+ const optimization = input.persistedPosture
1253
+ ? OPTIMIZATION_POSTURE_SUMMARY_LABELS[input.persistedPosture]
1254
+ : input.isNewProject
1255
+ ? "Quality"
1256
+ : "unchanged";
1257
+ const guidanceLabel = input.claudeCodeSelected ? "Claude Code guidance" : "Agent guidance";
1258
+ const guidanceValue = input.guidance.written.length > 0
1259
+ ? `added to ${input.guidance.written.join(", ")}`
1260
+ : input.guidance.unchanged.length > 0
1261
+ ? "already up to date"
1262
+ : "not written";
1263
+ const leftTop = `Project name: ${input.displayName}`;
1264
+ const leftBottom = `Optimization: ${optimization}`;
1265
+ const rightTop = input.ticketPrefix
1266
+ ? `Ticket keys: ${input.ticketPrefix}-1, ${input.ticketPrefix}-2, …`
1267
+ : undefined;
1268
+ const rightBottom = `${guidanceLabel}: ${guidanceValue}`;
1269
+ const width = Math.max(leftTop.length, leftBottom.length) + 3;
1270
+ const rowTop = rightTop === undefined ? ` ${leftTop}` : ` ${leftTop.padEnd(width)}${rightTop}`;
1271
+ const rowBottom = ` ${leftBottom.padEnd(width)}${rightBottom}`;
1272
+ return [
1273
+ "Bridge chose these defaults — change them later on the Setup page, or ask your agent:",
1274
+ rowTop,
1275
+ rowBottom,
1276
+ ].join("\n");
1277
+ }
1276
1278
  /**
1277
1279
  * Echoed single-line prompt on stderr (used for repo confirmation / value).
1278
1280
  * Exported so `index.ts`'s `--init` can share the exact same prompt mechanics
@@ -1296,56 +1298,6 @@ export function promptLineViaReadline(promptText, input = process.stdin, output
1296
1298
  });
1297
1299
  });
1298
1300
  }
1299
- /** Observe CR/LF before readline, counting split CRLF as one submission. */
1300
- export function promptGuidanceLineViaReadline(promptText, input = process.stdin, output = process.stderr) {
1301
- return new Promise((resolve) => {
1302
- const readable = input;
1303
- if (readable.readableEnded || readable.destroyed) {
1304
- resolve({ kind: "eof" });
1305
- return;
1306
- }
1307
- let terminators = 0;
1308
- let previousCR = false;
1309
- let settled = false;
1310
- let rl;
1311
- const observe = (chunk) => {
1312
- for (const char of chunk.toString()) {
1313
- if (char === "\r")
1314
- terminators++;
1315
- else if (char === "\n" && !previousCR)
1316
- terminators++;
1317
- previousCR = char === "\r";
1318
- }
1319
- };
1320
- const finish = (result) => {
1321
- if (settled)
1322
- return;
1323
- settled = true;
1324
- input.removeListener("data", observe);
1325
- input.removeListener("end", eof);
1326
- input.removeListener("close", eof);
1327
- input.removeListener("error", eof);
1328
- rl?.removeListener("close", eof);
1329
- rl?.close();
1330
- resolve(result);
1331
- };
1332
- const eof = () => finish({ kind: "eof" });
1333
- input.prependListener("data", observe);
1334
- input.once("end", eof);
1335
- input.once("close", eof);
1336
- input.once("error", eof);
1337
- rl = readline.createInterface({ input, output });
1338
- rl.once("close", eof);
1339
- if (settled || readable.readableEnded || readable.destroyed) {
1340
- rl.close();
1341
- eof();
1342
- return;
1343
- }
1344
- rl.question(promptText, (value) => {
1345
- finish(terminators > 0 ? { kind: "answer", value } : { kind: "eof" });
1346
- });
1347
- });
1348
- }
1349
1301
  /**
1350
1302
  * Default pre-warm spawner (BAPI-451 W3). Spawns array-based (`shell: false`) so
1351
1303
  * no argument is ever shell-interpreted, ignores all child stdio (node-gyp /
@@ -1461,8 +1413,9 @@ export function createDefaultInstallBridgeDeps() {
1461
1413
  randomBytes: (size) => cryptoRandomBytes(size),
1462
1414
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
1463
1415
  promptLine: isTTY ? promptLineViaReadline : undefined,
1464
- promptGuidanceLine: isTTY ? promptGuidanceLineViaReadline : undefined,
1465
- promptMultiSelect: isTTY ? promptMultiSelectViaReadline : undefined,
1416
+ promptMultiSelect: isTTY
1417
+ ? (promptText, options, preselected) => promptMultiSelectViaReadline(promptText, options, process.stdin, process.stderr, preselected)
1418
+ : undefined,
1466
1419
  vendor: createDefaultVendorProcessDeps(spawn),
1467
1420
  fetch: productionFetch,
1468
1421
  resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
@@ -1522,6 +1475,19 @@ export function createDefaultInstallBridgeDeps() {
1522
1475
  // Read-only service-state probe (BAPI-779). Typed to return a state,
1523
1476
  // never a runner, so the doctor cannot start/enable/reload anything.
1524
1477
  inspectExecutorServiceState: params.inspectExecutorServiceState,
1478
+ // BAPI-1461: only the converging setup passes these; without them the
1479
+ // conductor doctor is built exactly as before.
1480
+ ...(params.skipDenyProbe !== undefined || params.storedMergeCredential !== undefined
1481
+ ? {
1482
+ conductorDoctorDeps: {
1483
+ env: params.env,
1484
+ ...(params.skipDenyProbe === true ? { skipDenyProbe: true } : {}),
1485
+ ...(params.storedMergeCredential !== undefined
1486
+ ? { storedMergeCredential: params.storedMergeCredential }
1487
+ : {}),
1488
+ },
1489
+ }
1490
+ : {}),
1525
1491
  installDoctorDeps: {
1526
1492
  env: params.env,
1527
1493
  cwd: params.cwd,
@@ -1687,6 +1653,54 @@ export function getMergeWorkflowInstallUsage() {
1687
1653
  * `shell: false` — and `gh` additionally receives the non-interactive pins so a
1688
1654
  * prompt can never hang an install. The resulting environment is never printed.
1689
1655
  */
1656
+ /**
1657
+ * BAPI-1461 — the production seams for the converging `install conductor`.
1658
+ *
1659
+ * Each one is a thin adapter over an implementation that already exists: the
1660
+ * Claude login reader, one bounded `gh auth status`, the credential store's
1661
+ * typed merge-token functions (locked, durable writes), the shared capability
1662
+ * probe, the stored-test-command client, the CI workflow artifact-set installer
1663
+ * (sharing the merge-workflow installer's git/gh runners), and the parse routes.
1664
+ */
1665
+ export function buildConductorSetupDeps(deps, log, errorLog) {
1666
+ const installerDeps = buildMergeWorkflowInstallDeps(deps, log, errorLog);
1667
+ const storeDeps = modernWriteDeps(deps);
1668
+ return {
1669
+ detectClaudeLogin: () => detectClaudeLogin({ homedir: deps.homedir, readFile: deps.readFile }),
1670
+ // An injected installer runner (tests) is used for these reads too, so a
1671
+ // harness never reaches the real `gh` or `git`.
1672
+ checkGithubSignIn: () => deps.mergeWorkflowRunGh
1673
+ ? checkGithubSignIn(async (args) => ({ exitCode: (await deps.mergeWorkflowRunGh(args)).status }))
1674
+ : checkGithubSignIn(),
1675
+ readLocalGithubRemote: () => readLocalGithubRemote(deps.cwd, deps.mergeWorkflowRunGit
1676
+ ? async () => {
1677
+ const r = await deps.mergeWorkflowRunGit(["remote", "get-url", "origin"]);
1678
+ return r.status === 0 ? r.stdout : null;
1679
+ }
1680
+ : undefined),
1681
+ ...(deps.isTTY && deps.promptSecret ? { promptSecret: deps.promptSecret } : {}),
1682
+ readMergeToken: (identity) => readGithubMergeCredential(identity, storeDeps),
1683
+ storeMergeToken: (identity, token) => upsertGithubMergeCredential(identity, token, storeDeps),
1684
+ removeMergeToken: (identity) => removeGithubMergeCredential(identity, storeDeps),
1685
+ probeMergeToken: (args) => probeStoredMergeToken({ ...args, env: deps.env }),
1686
+ testingConfig: (access) => ({
1687
+ repoName: access.repoName,
1688
+ urls: createBridgeApiUrls(access.baseUrl),
1689
+ fetch: deps.fetch,
1690
+ getGetHeaders: async () => ({ "X-API-Key": access.apiKey }),
1691
+ getPostHeaders: async () => ({ "X-API-Key": access.apiKey, "Content-Type": "application/json" }),
1692
+ }),
1693
+ readRepositoryFile: (relPath) => deps.readFile(path.join(deps.cwd, relPath)),
1694
+ installCiWorkflow: (testCommand, options, confirmWrite) => installEpicCiWorkflow(testCommand, { dryRun: options.dryRun }, {
1695
+ ...installerDeps,
1696
+ ...(options.quiet === true ? { log: () => { }, errorLog: () => { } } : {}),
1697
+ confirmWrite,
1698
+ }),
1699
+ runGh: installerDeps.runGh,
1700
+ fetchParseStatus: (access) => fetchParseStatus(access, deps.fetch),
1701
+ triggerParse: (access) => triggerRepositoryParse(access, deps.fetch),
1702
+ };
1703
+ }
1690
1704
  export function buildMergeWorkflowInstallDeps(deps, log, errorLog) {
1691
1705
  const toResult = (r) => ({
1692
1706
  status: r.exitCode,
@@ -2148,7 +2162,7 @@ export function buildInstallBridgeMaintenanceHint() {
2148
2162
  "Persistent host registrations remain pinned until an explicit upgrade; the editor may need to reconnect to use the new server.",
2149
2163
  ];
2150
2164
  }
2151
- export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_BASE_URL) {
2165
+ export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_BASE_URL, onboarding) {
2152
2166
  // BAPI-757 (R-3 / AC-9): self-serve signup now REQUIRES an interactive terminal,
2153
2167
  // and the check runs FIRST — before `--email` and before `BAPI_SIGNUP_EMAIL` —
2154
2168
  // so an unattended run fails before any request is made, no verification email
@@ -2159,6 +2173,13 @@ export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_B
2159
2173
  if (!deps.isTTY || !deps.promptLine) {
2160
2174
  return { ok: false, error: INSTALL_BRIDGE_SIGNUP_REQUIRES_TTY };
2161
2175
  }
2176
+ // BAPI-1448: an email already answered at the merged entry prompt is consumed the
2177
+ // way `--email` is, with no second question and no second privacy notice.
2178
+ if (onboarding) {
2179
+ if (!onboarding.privacyNoticeShown)
2180
+ deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
2181
+ return { ok: true, value: onboarding.email };
2182
+ }
2162
2183
  deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
2163
2184
  if (typeof options.email === "string" && options.email.trim().length > 0) {
2164
2185
  return { ok: true, value: options.email.trim() };
@@ -2222,49 +2243,24 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
2222
2243
  return { kind: "have-key" };
2223
2244
  }
2224
2245
  /**
2225
- * The bare-TTY onboarding question (BAPI-708, Part A), replacing the BAPI-667/687
2226
- * three-option chooser.
2227
- *
2228
- * The three-option form asked the user to make a distinction the CODE can already
2229
- * make for itself: whether the string in their clipboard is an invite or an API
2230
- * key. `classifyEnteredCredential` + `isHighEntropyApiKeyShape` decide that from
2231
- * SHAPE alone, with no probe request — so the only thing a first-time user
2232
- * genuinely knows, and the only thing worth asking, is whether they have a token
2233
- * at all. Hence a yes/no question, with the route inferred afterwards.
2234
- *
2235
- * Interaction contract is unchanged from BAPI-667/687 and remains load-bearing:
2236
- * render the heading and the numbered options exactly once, accept ONE strict
2237
- * line, no default (a bare Enter selects nothing), bounded to a single re-prompt
2238
- * after one hint, and EOF resolves as an empty answer so it can never hang.
2246
+ * The bare-TTY entry prompt (BAPI-1448). One question replaces the BAPI-708 "do you
2247
+ * have a token yet? 1/2" chooser: a typed email selects self-serve signup, and a
2248
+ * blank answer selects the have-key branch, whose value is read ONLY through the
2249
+ * hidden `Paste your Bridge token (input hidden): ` prompt downstream.
2239
2250
  */
2240
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Welcome to Bridge - do you have a token yet?";
2241
- /**
2242
- * The question's numbered options, in render order. Index + 1 is the accepted
2243
- * token. BAPI-1171 swapped the order so the self-serve, need-a-token path is
2244
- * option 1 — first-time signup is the common case and now leads.
2245
- *
2246
- * "Token" is deliberately credential-agnostic: an invite and an API key are both
2247
- * tokens as far as this question is concerned, and answering "2" leads to ONE
2248
- * hidden prompt whose value selects the route by shape.
2249
- */
2250
- export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
2251
- "1. I need a token",
2252
- "2. I already have a token",
2253
- ];
2254
- /** The single-line answer prompt drawn after the options (no bracketed default). */
2255
- export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1 or 2: ";
2256
- /** The one hint line printed after a blank/invalid answer, before the single re-prompt. */
2257
- export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1 or 2.";
2251
+ export const INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT = "Email (or press Enter if you already have a token): ";
2252
+ /** The one hint line printed after a non-blank malformed answer, before the single re-prompt. */
2253
+ export const INSTALL_BRIDGE_EMAIL_OR_TOKEN_HINT = "Enter an email address, or press Enter if you already have a token.";
2258
2254
  /**
2259
2255
  * The one place the "no usable credential" copy lives (BAPI-667, U1-3).
2260
2256
  *
2261
- * Centralized because the chooser failure and {@link resolveApiKey}'s two failure
2257
+ * Centralized because the entry-prompt failure and {@link resolveApiKey}'s two failure
2262
2258
  * branches previously drifted: only one of them mentioned self-serve, and one
2263
2259
  * advised "try the hidden prompt again" — false, because the process has already
2264
2260
  * exited by the time the user reads it. All three now name the SAME three routes.
2265
2261
  */
2266
- export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install: on a bare interactive run answer 1 to request a new token or 2 if you " +
2267
- "already have one (an invite or an API key — either one is accepted at the hidden prompt). " +
2262
+ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install: on a bare interactive run, type your email to sign up, or press Enter if you " +
2263
+ "already have a token (an invite or an API key — either one is accepted at the hidden prompt). " +
2268
2264
  "Non-interactively, pass --api-key <key> (or set BAPI_API_KEY) for an " +
2269
2265
  "existing project, --invite if you were sent an invite token, or --email <addr> to sign up.";
2270
2266
  /**
@@ -2312,7 +2308,7 @@ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install: on a bare int
2312
2308
  * key-on-invite exits non-zero) is preserved BY CONSTRUCTION. Do not "unify" the
2313
2309
  * two.
2314
2310
  */
2315
- export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
2311
+ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv, baseUrl = DEFAULT_BAPI_BASE_URL) {
2316
2312
  const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
2317
2313
  // An explicit need-key intent is already unambiguous — never re-ask it.
2318
2314
  if (branch.kind === "need-key")
@@ -2324,38 +2320,35 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
2324
2320
  }
2325
2321
  const promptLine = deps.promptLine;
2326
2322
  try {
2327
- // Render the heading + options EXACTLY once; a re-prompt after an invalid
2328
- // answer re-asks the question without redrawing the list (BAPI-663 contract).
2329
- deps.log(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT);
2330
- for (const choice of INSTALL_BRIDGE_ONBOARDING_CHOICES)
2331
- deps.log(choice);
2323
+ // The answer may be an email, so the privacy notice comes first and prints once.
2324
+ deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
2332
2325
  // Bounded to two reads total: the first answer, then ONE re-prompt after a
2333
2326
  // single hint line. EOF resolves as an empty answer (promptLineViaReadline
2334
- // resolves "" on close), so it costs one re-prompt and then fails cleanly —
2335
- // it can never hang or silently select a branch.
2327
+ // resolves "" on close), which selects the have-key branch rather than hanging.
2336
2328
  for (let attempt = 0; attempt < 2; attempt += 1) {
2337
- const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
2338
- // BAPI-708 (A-1): the accepted tokens are exactly 1/2 (no letters, no
2339
- // aliases), preserving the one-strict-line contract. BAPI-1171 swapped the
2340
- // order so "1" is the self-serve need-key branch and "2" routes to the
2341
- // have-key branch's credential-agnostic prompt, whose value is classified by
2342
- // SHAPE — so a user holding an invite still lands in the redemption protocol
2343
- // without ever having been asked which kind of token they hold.
2344
- if (answer === "1") {
2329
+ const answer = String((await promptLine(INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT)) ?? "").trim();
2330
+ // Blank (or EOF): the user has a token; it is read only by the hidden prompt.
2331
+ if (answer.length === 0) {
2332
+ return { ok: true, branch: { kind: "have-key" }, credentialIntent: "inferred" };
2333
+ }
2334
+ // A typed value is accepted ONLY as a signup email. It is never classified as a
2335
+ // credential, logged, or persisted here; a token-shaped value fails the shape
2336
+ // check and gets the hint.
2337
+ if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(answer)) {
2345
2338
  return {
2346
2339
  ok: true,
2347
2340
  branch: { kind: "need-key", method: "self-serve" },
2348
2341
  credentialIntent: "inferred",
2342
+ onboardingEmail: answer,
2343
+ privacyNoticeShown: true,
2349
2344
  };
2350
2345
  }
2351
- if (answer === "2")
2352
- return { ok: true, branch: { kind: "have-key" }, credentialIntent: "inferred" };
2353
2346
  if (attempt === 0)
2354
- deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
2347
+ deps.log(INSTALL_BRIDGE_EMAIL_OR_TOKEN_HINT);
2355
2348
  }
2356
2349
  return {
2357
2350
  ok: false,
2358
- error: `No option was selected. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
2351
+ error: `No email or token choice was made. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
2359
2352
  };
2360
2353
  }
2361
2354
  catch {
@@ -2464,22 +2457,31 @@ const SELECTION_TOKEN_PATTERN = /^[1-9][0-9]*$/;
2464
2457
  * Interactive single-shot numbered picker on stderr (TTY only). Renders the
2465
2458
  * heading and each option exactly once, then accepts ONE strict
2466
2459
  * comma-separated line of one-based numbers and resolves immediately — there
2467
- * is no preselected/toggle state and no separate confirmation step (BAPI-663).
2460
+ * is no toggle state and no separate confirmation step (BAPI-663).
2461
+ *
2462
+ * BAPI-1448: when `preselected` names at least one offered option, those rows
2463
+ * render as `[x]` (the rest as `[ ]`) and a blank line accepts the ticked set.
2464
+ * Typed numbers REPLACE the ticked set; they never toggle it. With nothing
2465
+ * pre-ticked the original behaviour is unchanged: a blank line is rejected with
2466
+ * "Select at least one tool."
2468
2467
  *
2469
- * A blank line or a line containing any invalid token (non-numeric, zero,
2470
- * signed, leading-zero, decimal, out-of-range) is rejected atomically — the
2471
- * whole line is discarded, one error line is printed, and the picker
2472
- * re-prompts without re-rendering the option list. A valid line is
2473
- * deduplicated and returned in `options` order regardless of entry order.
2468
+ * A line containing any invalid token (non-numeric, zero, signed,
2469
+ * leading-zero, decimal, out-of-range) is rejected atomically — the whole line
2470
+ * is discarded, one error line is printed, and the picker re-prompts without
2471
+ * re-rendering the option list, so the ticks stay as first shown. A valid line
2472
+ * is deduplicated and returned in `options` order regardless of entry order.
2474
2473
  *
2475
2474
  * Resolves to `[]` on EOF / synchronous close before any line has been
2476
2475
  * committed, so a readline regression can never hang. Returns `[]`
2477
2476
  * immediately, without creating a readline interface, when `options` is
2478
2477
  * empty.
2479
2478
  */
2480
- export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr) {
2479
+ export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr, preselected = []) {
2481
2480
  if (options.length === 0)
2482
2481
  return Promise.resolve([]);
2482
+ // Canonical option order, restricted to options actually offered.
2483
+ const ticked = options.filter((o) => preselected.includes(o.id)).map((o) => o.id);
2484
+ const hasTicks = ticked.length > 0;
2483
2485
  return new Promise((resolve) => {
2484
2486
  const rl = readline.createInterface({ input, output });
2485
2487
  let settled = false;
@@ -2496,13 +2498,20 @@ export function promptMultiSelectViaReadline(promptText, options, input = proces
2496
2498
  rl.on("close", () => finish([]));
2497
2499
  output.write(`\n${promptText}\n`);
2498
2500
  options.forEach((opt, idx) => {
2499
- output.write(` ${idx + 1}. ${opt.label}\n`);
2501
+ const box = hasTicks ? `${ticked.includes(opt.id) ? "[x]" : "[ ]"} ` : "";
2502
+ output.write(` ${idx + 1}. ${box}${opt.label}\n`);
2500
2503
  });
2501
- output.write("Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
2504
+ output.write(hasTicks
2505
+ ? "Press Enter to use the ticked tools, or type the numbers you use (e.g. 1,3).\n"
2506
+ : "Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
2502
2507
  const prompt = () => {
2503
2508
  rl.question("> ", (answer) => {
2504
2509
  const trimmed = answer.trim();
2505
2510
  if (trimmed.length === 0) {
2511
+ if (hasTicks) {
2512
+ finish(ticked);
2513
+ return;
2514
+ }
2506
2515
  output.write("Select at least one tool.\n");
2507
2516
  prompt();
2508
2517
  return;
@@ -2553,10 +2562,6 @@ export function optimizationPostureSubscriptionNounPhrase(primaryPlatform) {
2553
2562
  const mapped = primaryPlatform ? OPTIMIZATION_POSTURE_SUBSCRIPTION_NAMES[primaryPlatform] : undefined;
2554
2563
  return mapped ? `${mapped} subscription` : "coding subscription";
2555
2564
  }
2556
- /** Option three's exact wording, platform-aware (BAPI-1221 Step 2). */
2557
- function buildOptimizationPostureOptionThree(primaryPlatform) {
2558
- return `Get the most out of your ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)}?`;
2559
- }
2560
2565
  /** The neutral single-line correction reprompted for any malformed answer. */
2561
2566
  const OPTIMIZATION_POSTURE_CORRECTION = "Enter a single number from 1 to 3.";
2562
2567
  /**
@@ -2572,20 +2577,21 @@ const OPTIMIZATION_POSTURE_CORRECTION = "Enter a single number from 1 to 3.";
2572
2577
  const OPTIMIZATION_POSTURE_MAX_CORRECTIONS = 20;
2573
2578
  /**
2574
2579
  * Render the one-time posture menu and its input instruction. Exactly three
2575
- * vertically ordered numbered options; option three is platform-aware. Plain,
2576
- * wrap-safe text — no color-only state, icons, or decorative framing.
2580
+ * vertically ordered numbered options; option three names the user's own
2581
+ * subscription. Plain, wrap-safe text (every line within 80 columns) — no
2582
+ * color-only state, icons, or decorative framing.
2577
2583
  */
2578
2584
  function buildOptimizationPostureMenu(primaryPlatform) {
2579
- return ("How should Bridge optimize its work on this repository?\n" +
2580
- " 1. Quality — fuller planning and review policy.\n" +
2581
- " 2. Speed — a combined planning pass and normally one review round.\n" +
2582
- ` 3. ${buildOptimizationPostureOptionThree(primaryPlatform)} — leaner planning/review defaults\n` +
2583
- " and lower harness model tiers where supported.\n" +
2584
- "These are active defaults, subject to explicit policy/model overrides.\n" +
2585
- `Your ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)} is separate from Bridge credits.\n` +
2586
- "Enter keeps an existing saved preference; projects without one use Quality.\n" +
2587
- "You can change this later with /teach-bridge.\n" +
2588
- `${OPTIMIZATION_POSTURE_CORRECTION} `);
2585
+ return ("How should Bridge balance quality and cost on this project?\n" +
2586
+ " 1. Quality — Bridge plans and reviews each ticket in full depth; slowest and\n" +
2587
+ " uses the most credits, but catches the most problems.\n" +
2588
+ " 2. Speed — Bridge plans in one pass and reviews once; faster and cheaper, but\n" +
2589
+ " may miss things on some tickets.\n" +
2590
+ " 3. Token Efficiency — Bridge will use Speed settings and hand simpler tickets\n" +
2591
+ " to smaller models more often, which stretches your\n" +
2592
+ ` ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)} the furthest.\n` +
2593
+ "Press Enter to keep Quality (or your saved choice).\n" +
2594
+ "Enter 1, 2, or 3: ");
2589
2595
  }
2590
2596
  /**
2591
2597
  * The purpose-built, strict single-select optimization-posture picker
@@ -2675,7 +2681,7 @@ function optimizationPostureConfirmationPhrase(value, primaryPlatform) {
2675
2681
  }
2676
2682
  /** Printed immediately after a successful posture write, on its own line. */
2677
2683
  const OPTIMIZATION_POSTURE_BLOG_URL = "https://bridgegpt.ai/blog/how-we-optimize-your-setup";
2678
- const OPTIMIZATION_POSTURE_REMEDIATION = "Update it later with /teach-bridge or the optimization_posture setting in the web UI.";
2684
+ const OPTIMIZATION_POSTURE_REMEDIATION = "Change it later by running /teach-bridge in your agent.";
2679
2685
  /**
2680
2686
  * The generic, secret-free fail-open message shared by every non-success,
2681
2687
  * non-forbidden outcome — an interactive no-answer/prompt-exception (no PUT
@@ -2709,8 +2715,9 @@ function reportOptimizationPostureOutcome(log, result, value, primaryPlatform) {
2709
2715
  * Resolve the selected host platforms with strict precedence (BAPI-635):
2710
2716
  * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
2711
2717
  * back to detection),
2712
- * 2. interactive multi-select on a TTY (AC-2: no row pre-selected — the picker
2713
- * starts empty and requires an explicit ≥1 selection),
2718
+ * 2. interactive multi-select on a TTY, pre-ticked with the detected tools
2719
+ * (BAPI-1448 reverses the BAPI-635 AC-2 empty picker; with nothing detected
2720
+ * the picker still requires an explicit ≥1 selection),
2714
2721
  * 3. the legacy non-TTY automatic set: Claude Code plus only the currently
2715
2722
  * detected Copilot VS Code and Cursor automatic targets (never Codex just
2716
2723
  * because a global directory exists).
@@ -2720,11 +2727,12 @@ export async function resolveSelectedHostPlatforms(deps, options) {
2720
2727
  if (options.tools !== undefined) {
2721
2728
  return options.tools;
2722
2729
  }
2723
- // 2. Interactive single-shot numbered picker on a TTY. No row is pre-selected
2724
- // — the picker has no default state and requires an explicit selection.
2730
+ // 2. Interactive single-shot numbered picker on a TTY, seeded with the locally
2731
+ // detected tools (BAPI-1448): Enter accepts them, typed numbers replace them.
2725
2732
  if (deps.isTTY && deps.promptMultiSelect) {
2726
2733
  const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
2727
- const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList);
2734
+ const detected = detectDefaultPlatforms(await buildDetectionContext(deps));
2735
+ const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, detected);
2728
2736
  return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
2729
2737
  }
2730
2738
  // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
@@ -2842,42 +2850,6 @@ export function toolLabelForLaunchAgent(agent) {
2842
2850
  const target = allHostTargets().find((t) => t.launchAgent === agent);
2843
2851
  return target?.label ?? UNKNOWN_LAUNCH_TOOL_LABEL;
2844
2852
  }
2845
- /**
2846
- * Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
2847
- * (AC-11). Mirrors the connect-github picker: prints each candidate by its host-tool
2848
- * label, asks for an exact numeric choice with NO default, validates the range, and
2849
- * returns the chosen agent. Returns `null` — never a silent first-candidate default —
2850
- * for every unavailable/invalid path: non-TTY, a missing `promptLine` seam, blank/EOF
2851
- * input, an out-of-range or malformed answer, or a prompt that throws. Single-purpose:
2852
- * it must run only AFTER the multi-select promise has resolved and closed its readline
2853
- * interface so two readers never contend for stdin.
2854
- */
2855
- export async function chooseInstallBridgeLaunchAgent(agents, deps) {
2856
- if (!deps.isTTY || !deps.promptLine)
2857
- return null;
2858
- const promptLine = deps.promptLine;
2859
- try {
2860
- deps.log("");
2861
- deps.log("More than one selected tool can host the configuration session:");
2862
- agents.forEach((agent, i) => {
2863
- deps.log(` ${String(i + 1).padStart(2, " ")}. ${toolLabelForLaunchAgent(agent)}`);
2864
- });
2865
- // No default: a stray Enter must not silently pick a tool.
2866
- const answer = (await promptLine(`Which tool should open? [1-${agents.length}]: `)).trim();
2867
- const index = Number(answer);
2868
- if (!/^\d+$/.test(answer) ||
2869
- !Number.isInteger(index) ||
2870
- index < 1 ||
2871
- index > agents.length) {
2872
- return null;
2873
- }
2874
- return agents[index - 1];
2875
- }
2876
- catch {
2877
- // Never surface caught exception text; an unavailable chooser is a no-spawn.
2878
- return null;
2879
- }
2880
- }
2881
2853
  /**
2882
2854
  * Shared, copy-pasteable "we didn't open a session for you" guidance so every
2883
2855
  * no-spawn path (no-launchable selection, non-TTY suppression, chooser failure,
@@ -3962,7 +3934,7 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
3962
3934
  return {
3963
3935
  ok: false,
3964
3936
  kind: "failed",
3965
- message: `The bootstrap exchange failed (HTTP ${resp.status}). Preserve proof and rerun the same attempt; verify server compatibility and contact installation support if it persists.`,
3937
+ message: `The bootstrap exchange failed (HTTP ${resp.status}). Preserve proof and rerun the same attempt; verify server compatibility and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL} if it persists.`,
3966
3938
  };
3967
3939
  }
3968
3940
  /** The invite-email prompt (BAPI-1263), shown once after an `email_required` refusal. */
@@ -4128,6 +4100,27 @@ export function buildSelfServeMintUrl(baseUrl) {
4128
4100
  export const SIGNUP_VERIFICATION_CODE_LENGTH = 6;
4129
4101
  /** The one prompt label. The E2E harness and the static contract both pin it. */
4130
4102
  export const INSTALL_BRIDGE_CODE_PROMPT = "Email verification code: ";
4103
+ /**
4104
+ * The address verification codes are sent from. Mirrors the server's
4105
+ * `DEFAULT_FROM_ADDRESS` in `api/library/email/provider.py`; keep the two in step.
4106
+ */
4107
+ export const INSTALL_BRIDGE_VERIFICATION_SENDER = "noreply@bridgegpt-api.com";
4108
+ /**
4109
+ * The shared lead-in printed immediately before every self-serve
4110
+ * {@link promptForVerificationCode} call (modern and legacy paths, fresh or resumed):
4111
+ * three unindented lines, then a blank line. It prints neither the prompt nor any
4112
+ * expiry or time-left text.
4113
+ *
4114
+ * BAPI-1449 (verification recovery) builds on this lead-in instead of adding its
4115
+ * own, and may later return the sender from the request endpoint rather than using
4116
+ * {@link INSTALL_BRIDGE_VERIFICATION_SENDER}.
4117
+ */
4118
+ export function logVerificationCodeLeadIn(log, email) {
4119
+ log(`We emailed a ${SIGNUP_VERIFICATION_CODE_LENGTH}-digit code to ${maskSignupEmail(email)}.`);
4120
+ log(`It usually arrives within two minutes. It comes from ${INSTALL_BRIDGE_VERIFICATION_SENDER} — check your`);
4121
+ log("spam or junk folder if you don't see it.");
4122
+ log("");
4123
+ }
4131
4124
  /** Shown when a stored, unexpired challenge is picked up on a later run. */
4132
4125
  export const INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING = "Resume email verification";
4133
4126
  /**
@@ -4255,6 +4248,95 @@ export const INSTALL_BRIDGE_SIGNUP_BLOCKED = "Error: Bridge signup is not availa
4255
4248
  export function formatSelfServeUnexpectedStatus(status) {
4256
4249
  return `Error: Bridge signup returned an unexpected response (status ${status}). Try again in a few minutes.`;
4257
4250
  }
4251
+ // ===================================================================
4252
+ // BAPI-1449 — modern-path verification recovery copy (S1–S7).
4253
+ //
4254
+ // The wording is settled; only the sender comes from the shared
4255
+ // INSTALL_BRIDGE_VERIFICATION_SENDER constant. None of these says WHY a server
4256
+ // rejection happened: the word "expired" appears only in S4a, which is chosen from
4257
+ // the locally stored expiry and never from a server reply.
4258
+ // ===================================================================
4259
+ /** The existing end-of-run line for a prompt that gave up or timed out empty. */
4260
+ export const INSTALL_BRIDGE_VERIFICATION_INCOMPLETE = "Verification incomplete. Resume the same signup from this folder.";
4261
+ /** S1 — the first empty Enter at the modern code prompt. */
4262
+ export const INSTALL_BRIDGE_EMPTY_CODE_GUIDANCE = [
4263
+ "No code yet? Type r to send a new one, or check spam for mail from",
4264
+ `${INSTALL_BRIDGE_VERIFICATION_SENDER}.`,
4265
+ "Wrong address? Press Ctrl-C and re-run install with a different email.",
4266
+ "Have a teammate on Bridge? Ask them for an invite and choose",
4267
+ '"I already have a token".',
4268
+ ].join("\n");
4269
+ /** S2 — after a rejected code while resends may remain. */
4270
+ export const INSTALL_BRIDGE_REJECTED_RESEND_HINT = "Type r to send a new code, or enter the code again.";
4271
+ /** S3 — `r` after the server reported no resends left. */
4272
+ export const INSTALL_BRIDGE_NO_MORE_CODES = [
4273
+ "Bridge cannot send more codes for this signup.",
4274
+ "Enter a code from an email you already received.",
4275
+ ].join("\n");
4276
+ /** S4a — the saved challenge is past its locally stored expiry. */
4277
+ export const INSTALL_BRIDGE_RECOVERY_REASON_EXPIRED = "The code for this signup has expired.";
4278
+ /** S4b — the saved signup has no challenge handle (or expiry). */
4279
+ export const INSTALL_BRIDGE_RECOVERY_REASON_NO_CODE = "The last signup in this folder did not finish, so no code is waiting.";
4280
+ /** S4c — this run's email differs from the saved signup's email. */
4281
+ export const INSTALL_BRIDGE_RECOVERY_REASON_DIFFERENT_EMAIL = "This folder has a signup waiting for a different email address.";
4282
+ /** S5 — the rejection cap was reached (state-neutral). */
4283
+ export const INSTALL_BRIDGE_RECOVERY_REASON_CAP = "That signup could not be verified. Nothing has been created.";
4284
+ /** S6 — the user declined a fresh signup. */
4285
+ export const INSTALL_BRIDGE_FRESH_SIGNUP_DECLINED = [
4286
+ "Nothing has been created. Your saved signup is kept.",
4287
+ "Re-run install from this folder when you are ready.",
4288
+ ].join("\n");
4289
+ /** S7 — the user accepted; printed after the old attempt is archived. */
4290
+ export const INSTALL_BRIDGE_FRESH_SIGNUP_STARTING = "Starting a new signup. The old attempt was set aside, not deleted.";
4291
+ /**
4292
+ * The default-No question after S4a/S4b/S4c/S5. Names only the MASKED address of
4293
+ * THIS run's email; the full address is never printed.
4294
+ */
4295
+ export function formatFreshSignupQuestion(thisRunEmail) {
4296
+ return `Start a new signup? We will email a new code to ${maskSignupEmail(thisRunEmail)}. [y/N]: `;
4297
+ }
4298
+ /** The legacy "could not be requested" line for a rejected email (HTTP 400/422). */
4299
+ export const INSTALL_BRIDGE_SELF_SERVE_REQUEST_INVALID = "Error: Self-serve setup could not be requested. Check the email value and try again.";
4300
+ /**
4301
+ * Map a failed challenge REQUEST to its user-facing line, with the legacy loop's
4302
+ * category mapping. Carries no response body, email, or exception text.
4303
+ */
4304
+ export function formatSelfServeChallengeRequestFailure(failure) {
4305
+ switch (failure.category) {
4306
+ case "rate-limited":
4307
+ return `Error: ${INSTALL_BRIDGE_RESEND_THROTTLED}`;
4308
+ case "unavailable":
4309
+ case "network-error":
4310
+ return INSTALL_BRIDGE_SIGNUP_UNAVAILABLE;
4311
+ case "invalid":
4312
+ return INSTALL_BRIDGE_SELF_SERVE_REQUEST_INVALID;
4313
+ case "blocked":
4314
+ return INSTALL_BRIDGE_SIGNUP_BLOCKED;
4315
+ case "unexpected":
4316
+ return formatSelfServeUnexpectedStatus(failure.status ?? 0);
4317
+ default:
4318
+ return INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED;
4319
+ }
4320
+ }
4321
+ /**
4322
+ * Map a failed VERIFY (other than the uniform `rejected`) to its user-facing line,
4323
+ * with the legacy loop's mapping. Carries no response body, code, or handle.
4324
+ */
4325
+ export function formatSelfServeVerifyFailure(failure) {
4326
+ switch (failure.category) {
4327
+ case "rate-limited":
4328
+ return `Error: ${INSTALL_BRIDGE_VERIFY_THROTTLED}`;
4329
+ case "server-error":
4330
+ case "network-error":
4331
+ return INSTALL_BRIDGE_VERIFY_SERVER_ERROR;
4332
+ case "blocked":
4333
+ return INSTALL_BRIDGE_SIGNUP_BLOCKED;
4334
+ case "unexpected":
4335
+ return formatSelfServeUnexpectedStatus(failure.status ?? 0);
4336
+ default:
4337
+ return INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED;
4338
+ }
4339
+ }
4258
4340
  /**
4259
4341
  * Mask an address for display: first character, then the domain.
4260
4342
  *
@@ -4296,23 +4378,10 @@ export function formatChallengeRemaining(expiresAtIso, nowMs) {
4296
4378
  const hours = Math.floor(minutes / 60);
4297
4379
  return `This code is valid for about another ${hours} hours.`;
4298
4380
  }
4299
- /**
4300
- * Prompt for the verification code, validating its shape LOCALLY before any
4301
- * network call.
4302
- *
4303
- * Local validation is a courtesy, not a security control — the server re-validates
4304
- * everything — but it keeps a mistyped code from consuming one of the five attempts
4305
- * that permanently kill a challenge. The user gets the prompt back instead.
4306
- *
4307
- * Pasted input is trimmed: copying a code out of a mail client routinely brings
4308
- * whitespace with it, and refusing that would be an avoidable dead end.
4309
- *
4310
- * The entered value is returned and never echoed, never logged, and never written
4311
- * to disk. `null` means the user gave up (an empty line, or an unreadable prompt).
4312
- */
4313
- export async function promptForVerificationCode(deps, log, maxAttempts = 3) {
4381
+ export async function promptForVerificationCode(deps, log, maxAttempts = 3, options = {}) {
4382
+ const allowResend = options.allowResend ?? false;
4314
4383
  if (!deps.promptLine)
4315
- return null;
4384
+ return { kind: "give-up" };
4316
4385
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
4317
4386
  let entered;
4318
4387
  try {
@@ -4321,17 +4390,19 @@ export async function promptForVerificationCode(deps, log, maxAttempts = 3) {
4321
4390
  catch {
4322
4391
  // Secret-free by construction: the caught value is never surfaced or
4323
4392
  // inspected, because a readline failure can carry the partial input.
4324
- return null;
4393
+ return { kind: "give-up" };
4325
4394
  }
4326
4395
  if (entered.length === 0)
4327
- return null;
4396
+ return { kind: "empty" };
4397
+ if (allowResend && entered.toLowerCase() === "r")
4398
+ return { kind: "resend" };
4328
4399
  if (new RegExp(`^[0-9]{${SIGNUP_VERIFICATION_CODE_LENGTH}}$`).test(entered)) {
4329
- return entered;
4400
+ return { kind: "code", code: entered };
4330
4401
  }
4331
4402
  // Describes the SHAPE, never the value the user typed.
4332
4403
  log(` That does not look like a code — it is ${SIGNUP_VERIFICATION_CODE_LENGTH} digits.`);
4333
4404
  }
4334
- return null;
4405
+ return { kind: "give-up" };
4335
4406
  }
4336
4407
  /*
4337
4408
  * `mintSelfServeInvite` and `SelfServeMintResult` were REMOVED in BAPI-757.
@@ -4898,8 +4969,8 @@ function buildDeferredLaunchStepPreview() {
4898
4969
  /**
4899
4970
  * The optional agent-notes offer preview line (BAPI-1207). A --dry-run never
4900
4971
  * prompts or writes; when the selection resolves to at least one writable
4901
- * notes target this states plainly that a real interactive run would ask
4902
- * default-No consent before touching it. Empty when the selection resolves to
4972
+ * notes target this states plainly that a real interactive run writes it
4973
+ * without asking. Empty when the selection resolves to
4903
4974
  * no writable target (e.g. Windsurf-only), mirroring how the GitHub offer
4904
4975
  * lines are unconditional but this offer's applicability is not.
4905
4976
  */
@@ -4914,10 +4985,10 @@ function buildAgentNotesPreviewLines(notesTargets, platformLabels) {
4914
4985
  "Step 4c — reached only after the Step 4b VCS connect offer has completed or been",
4915
4986
  " explicitly deferred (a declined or incomplete connect is announced first,",
4916
4987
  " with the command to finish it later, and never blocks this step).",
4917
- `Step 4c — optional: Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4918
- ` terminal is interactive, offer to write guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4919
- ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}; defaults`,
4920
- " to Yes [Y/n] on submitted Enter; EOF skips. Existing content outside that region is preserved. No notes files are",
4988
+ `Step 4c — Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4989
+ ` terminal is interactive, a real run writes guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4990
+ ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}, without asking,`,
4991
+ " then prints how to remove it. Existing content outside that region is preserved. No notes files are",
4921
4992
  " written in --dry-run.",
4922
4993
  ];
4923
4994
  }
@@ -5395,10 +5466,6 @@ export function isMinttyNonInteractiveInstall(platform, env, isTTY) {
5395
5466
  }
5396
5467
  export async function runInstallBridgeCli(argv, overrides = {}) {
5397
5468
  const deps = { ...createDefaultInstallBridgeDeps(), ...overrides };
5398
- if (Object.prototype.hasOwnProperty.call(overrides, "promptLine") &&
5399
- !Object.prototype.hasOwnProperty.call(overrides, "promptGuidanceLine")) {
5400
- deps.promptGuidanceLine = undefined;
5401
- }
5402
5469
  // When the caller overrode only `fetch` (the common test seam), bind the
5403
5470
  // default resolver to the FINAL merged fetch so server resolution still routes
5404
5471
  // through the override. An explicit `resolveRepoViaServer` override is honored.
@@ -5537,6 +5604,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5537
5604
  // Built from the FINAL merged deps for the same reason (BAPI-775): a test
5538
5605
  // that overrides only `readFile` or only `cwd` must see its override here.
5539
5606
  localProvisioning: buildConductorLocalDeps(deps),
5607
+ // BAPI-1461: the converging setup's seams (sign-ins, the stored merge token,
5608
+ // the stored test command, the CI workflow installer, and indexing).
5609
+ setup: deps.conductorSetup ?? buildConductorSetupDeps(deps, log, errorLog),
5540
5610
  // BAPI-1391: the read-only discovery filesystem over THIS working tree, and
5541
5611
  // the short-lived qualifying executor (the existing executor CLI with a
5542
5612
  // `runExecutor` override limiting it to `gate_qualification` jobs).
@@ -5749,7 +5819,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5749
5819
  // The selection is pure and deterministic for every explicit, env-driven, and
5750
5820
  // non-TTY invocation; only a BARE interactive run is asked which branch it wants
5751
5821
  // (BAPI-626 — otherwise a first-time user can never reach self-serve).
5752
- const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv);
5822
+ const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv, baseUrl);
5753
5823
  if (!branchResult.ok) {
5754
5824
  errorLog(`Error: ${branchResult.error}`);
5755
5825
  return 1;
@@ -5791,7 +5861,12 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5791
5861
  // key-authentication ping, so there is no source to attribute.
5792
5862
  let apiKeySource;
5793
5863
  if (selfServeSignupMode) {
5794
- const emailResult = await resolveSignupEmail(options, deps, baseUrl);
5864
+ const emailResult = await resolveSignupEmail(options, deps, baseUrl, branchResult.onboardingEmail
5865
+ ? {
5866
+ email: branchResult.onboardingEmail,
5867
+ privacyNoticeShown: branchResult.privacyNoticeShown === true,
5868
+ }
5869
+ : undefined);
5795
5870
  if (!emailResult.ok) {
5796
5871
  errorLog(`Error: ${emailResult.error}`);
5797
5872
  return 1;
@@ -5931,6 +6006,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5931
6006
  return 1;
5932
6007
  }
5933
6008
  let modernAttempt;
6009
+ // BAPI-1449: an expired basename-keyed signup is offered a fresh modern signup by
6010
+ // the modern path (consent first), instead of ending the run here.
6011
+ let legacyExpired;
5934
6012
  if (options.tools?.length === 0 && !options.agentName) {
5935
6013
  log(buildManualInstallBridgeContinuation("empty-selection", []));
5936
6014
  return 0;
@@ -5956,22 +6034,22 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5956
6034
  }
5957
6035
  if (legacy.state === "resumable" || legacy.state === "challenge")
5958
6036
  options.repo = oldName.value;
5959
- if (legacy.state === "challenge-expired") {
5960
- errorLog("Saved signup expired. Preserve proof and contact installation support.");
5961
- return 1;
5962
- }
6037
+ if (legacy.state === "challenge-expired")
6038
+ legacyExpired = { repoName: oldName.value, snapshot: legacy.snapshot };
5963
6039
  }
5964
6040
  }
5965
6041
  if (options.repo === undefined) {
5966
6042
  try {
5967
- modernAttempt = await redeemModernInstallAttempt(deps, options, baseUrl, inviteToken, signupEmail, selfServeSignupMode);
6043
+ modernAttempt = await redeemModernInstallAttempt(deps, options, baseUrl, inviteToken, signupEmail, selfServeSignupMode, legacyExpired);
5968
6044
  apiKey = modernAttempt.record.keySecret;
5969
6045
  options.repo = modernAttempt.repoName;
5970
6046
  bootstrapInviteMode = false;
5971
6047
  deps.log(`Project '${modernAttempt.displayName}' recovered; completing installation.`);
5972
6048
  }
5973
6049
  catch (error) {
5974
- errorLog(error instanceof ModernInstallRecoveryError ? error.message : "Installation could not complete. Preserve recovery proof and resume from the same folder and invite/signup; contact installation support if needed.");
6050
+ if (error instanceof ModernInstallStopped)
6051
+ return 1;
6052
+ errorLog(error instanceof ModernInstallRecoveryError ? error.message : `Installation could not complete. Preserve recovery proof and resume from the same folder and invite/signup; contact Bridge support at ${BRIDGE_SUPPORT_EMAIL} if needed.`);
5975
6053
  return 1;
5976
6054
  }
5977
6055
  }
@@ -6137,10 +6215,10 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6137
6215
  return 0;
6138
6216
  }
6139
6217
  // ---- Finalize the launch agent for spawn-capable decisions ----
6140
- // choose-one: pick exactly one via the numbered chooser (TTY only); a non-TTY run or
6141
- // a failed/aborted chooser degrades to the manual continuation (finalAgentName stays
6142
- // null). This runs AFTER the multi-select readline closed and BEFORE the GitHub /
6143
- // consent flows, so no two stdin readers ever overlap.
6218
+ // choose-one: on a TTY, take the first launchable agent the user selected and say so
6219
+ // (BAPI-1448 retired the launch-agent chooser; `--agent` still wins
6220
+ // upstream in the pure launch decision). A non-TTY run degrades to the manual
6221
+ // continuation (finalAgentName stays null).
6144
6222
  let finalAgentName = null;
6145
6223
  let finalSpawnCommand = null;
6146
6224
  if (planLaunch.kind === "spawn") {
@@ -6148,8 +6226,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6148
6226
  finalSpawnCommand = planLaunch.spawnCommand;
6149
6227
  }
6150
6228
  else if (planLaunch.kind === "choose-one") {
6151
- const chosen = await chooseInstallBridgeLaunchAgent(planLaunch.agents, deps);
6229
+ const chosen = deps.isTTY ? (planLaunch.agents[0] ?? null) : null;
6152
6230
  if (chosen) {
6231
+ log(`Using ${toolLabelForLaunchAgent(chosen)} for the setup session (the first tool you selected). Pass --agent to choose another.`);
6153
6232
  const spec = resolveAgentSpec(chosen);
6154
6233
  if (!spec) {
6155
6234
  errorLog(`Error: no launch agent is registered for '${chosen}'.`);
@@ -6159,7 +6238,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6159
6238
  const modelAlias = await resolveValidatedPremiumModelAlias(deps, spec);
6160
6239
  finalSpawnCommand = deps.buildShellCommand(spec, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform, modelAlias);
6161
6240
  }
6162
- // chosen === null → non-TTY / unavailable / invalid → manual continuation below.
6241
+ // chosen === null → non-TTY → manual continuation below.
6163
6242
  }
6164
6243
  // planLaunch.kind === "manual" (no-launchable) → finalAgentName stays null.
6165
6244
  // ---- Materialize the Step 5 launch command + kick off the pre-warm — ONLY for a
@@ -6348,7 +6427,6 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6348
6427
  challengeExpiresAt = liveChallenge.expiresAt;
6349
6428
  challengeEmail = liveChallenge.email;
6350
6429
  log(` ${INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING}`);
6351
- log(` We already emailed a code to ${maskSignupEmail(challengeEmail)}.`);
6352
6430
  }
6353
6431
  else {
6354
6432
  const requested = await requestSelfServeChallenge(deps, baseUrl, signupEmail);
@@ -6365,7 +6443,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6365
6443
  reportFatal(INSTALL_BRIDGE_SIGNUP_UNAVAILABLE);
6366
6444
  }
6367
6445
  else if (requested.category === "invalid") {
6368
- reportFatal("Error: Self-serve setup could not be requested. Check the email value and try again.");
6446
+ reportFatal(INSTALL_BRIDGE_SELF_SERVE_REQUEST_INVALID);
6369
6447
  }
6370
6448
  else if (requested.category === "blocked") {
6371
6449
  // BAPI-1171: the request was refused before signup logic ran — an
@@ -6408,12 +6486,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6408
6486
  "No workspace has been set up — fix the problem and re-run.");
6409
6487
  return false;
6410
6488
  }
6411
- log(` We emailed a code to ${maskSignupEmail(challengeEmail)}.`);
6412
6489
  }
6413
- log(` Enter the ${SIGNUP_VERIFICATION_CODE_LENGTH}-digit code to finish setting up your workspace.`);
6414
- const remaining = formatChallengeRemaining(challengeExpiresAt, deps.now());
6415
- if (remaining)
6416
- log(` ${remaining}`);
6490
+ // BAPI-1448: the shared lead-in, fresh or resumed, directly before code entry.
6491
+ logVerificationCodeLeadIn(log, challengeEmail);
6417
6492
  // Phase 2, second half, and phase 3. One resend is offered on the recovery
6418
6493
  // path only — never proactively — so the budget is spent on the user's
6419
6494
  // explicit request rather than by the CLI's own retry logic.
@@ -6424,12 +6499,15 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6424
6499
  // never succeed, with Ctrl-C as the only way out.
6425
6500
  let rejections = 0;
6426
6501
  for (;;) {
6427
- const code = await promptForVerificationCode(deps, log);
6428
- if (code === null) {
6502
+ // Resend recognition stays off here: on the legacy path `r` is still malformed
6503
+ // input, and an empty line or give-up ends the run as it always has.
6504
+ const prompted = await promptForVerificationCode(deps, log);
6505
+ if (prompted.kind !== "code") {
6429
6506
  reportFatal("Error: No verification code entered — nothing has been set up. Re-run this command " +
6430
6507
  "to continue; the code already emailed to you stays valid until it expires.");
6431
6508
  return false;
6432
6509
  }
6510
+ const code = prompted.code;
6433
6511
  const verified = await verifySelfServeChallenge(deps, baseUrl, challengeHandle, code);
6434
6512
  if (verified.ok) {
6435
6513
  inviteToken = verified.token;
@@ -6820,9 +6898,12 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6820
6898
  // existing value unconditionally — the old value is never read first. A
6821
6899
  // no-answer (or a prompt/stream exception the picker already absorbed)
6822
6900
  // reports the same fail-open remediation without ever attempting a write.
6901
+ let persistedPosture;
6823
6902
  if (optimizationPostureSelection) {
6824
6903
  const postureResult = await persistOptimizationPosture(deps, baseUrl, apiKey, repoName, optimizationPostureSelection);
6825
6904
  reportOptimizationPostureOutcome(log, postureResult, optimizationPostureSelection, selectedPlatforms[0]);
6905
+ if (postureResult.kind === "success")
6906
+ persistedPosture = optimizationPostureSelection;
6826
6907
  }
6827
6908
  else if (optimizationPostureEligible) {
6828
6909
  log(OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE);
@@ -7106,8 +7187,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
7106
7187
  // selected set. `writeAgentNotes` never touches `writeMcpInstallState` or any
7107
7188
  // other persisted install-state contract — consent is re-evaluated every run.
7108
7189
  const notesPlan = resolveAgentNotesTargets(selectedPlatforms);
7109
- const notesPlatformLabels = labelsForPlatforms(notesPlan.supportedPlatforms);
7110
- const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets, notesPlatformLabels);
7190
+ const notesOffer = offerAgentGuidanceNotes(deps, notesPlan.targets);
7111
7191
  let notesResult;
7112
7192
  let notesWriteFailed = false;
7113
7193
  if (notesOffer.kind === "consented") {
@@ -7137,10 +7217,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
7137
7217
  const notesSummaryLine = buildAgentNotesSummaryLine(notesOffer, notesResult, notesWriteFailed, notesPlan.unsupportedPlatforms);
7138
7218
  if (notesSummaryLine)
7139
7219
  log(notesSummaryLine);
7220
+ // The removal hint only when something was actually written (BAPI-1448).
7221
+ const notesWrittenTargets = (notesResult?.files ?? [])
7222
+ .filter((f) => f.status === "written")
7223
+ .map((f) => f.target);
7224
+ if (notesWrittenTargets.length > 0)
7225
+ log(INSTALL_BRIDGE_AGENT_NOTES_REMOVAL_HINT);
7226
+ const notesUnchangedTargets = (notesResult?.files ?? [])
7227
+ .filter((f) => f.status === "skipped")
7228
+ .map((f) => f.target);
7140
7229
  // ---- BAPI-1398: Bridge permission rules for /drive-epic-run (Claude Code) ----
7141
7230
  // After the durable install work and the other offers, before the session
7142
- // opens. Project rules are written as part of the normal install; the user
7143
- // settings change asks once and is skipped without a yes or a terminal. Every
7231
+ // opens. Project rules are written as part of the normal install. The user
7232
+ // settings change is NOT part of install (BAPI-1448): it lives in the
7233
+ // `bridge-permissions` subcommand, offered in-session by /drive-epic-run. Every
7144
7234
  // failure here is advisory: a malformed settings file is reported and skipped,
7145
7235
  // and the install still completes.
7146
7236
  if (selectedPlatforms.includes("claude-code")) {
@@ -7149,13 +7239,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
7149
7239
  cwd: deps.cwd,
7150
7240
  homeDir: deps.homedir(),
7151
7241
  env: deps.env,
7152
- promptLine: deps.isTTY ? deps.promptLine : undefined,
7153
7242
  readFile: deps.readFile,
7154
7243
  writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
7155
7244
  mkdir: (p, o) => deps.mkdir(p, o),
7156
7245
  log,
7157
7246
  errorLog,
7158
7247
  ensureGitignored: (relPath) => ensureGitignoredShared(deps.cwd, relPath, gitignoreDeps),
7248
+ // BAPI-1446: pin the SessionStart upgrade-notice hook to the effective Claude Code pin.
7249
+ sessionStartHook: { pinnedVersion: await discoverClaudeCodeEffectivePin({ cwd: deps.cwd, readFile: deps.readFile, targetVersion: VERSION }) },
7159
7250
  });
7160
7251
  }
7161
7252
  catch {
@@ -7187,6 +7278,27 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
7187
7278
  for (const line of buildInstallBridgeMaintenanceHint())
7188
7279
  log(line);
7189
7280
  await showFirstBuildAdvisory(finalAgentName);
7281
+ // ---- BAPI-1448: the defaults summary, once, right before the launch consent ----
7282
+ // Every input is already in memory except the optional ticket prefix, which is one
7283
+ // bounded read of the manifest the GitHub offer already uses. Any failure there just
7284
+ // omits the cell: the summary never blocks or changes the install's outcome.
7285
+ let ticketPrefix;
7286
+ try {
7287
+ const details = await fetchGithubConfigurationDetails({ fetch: deps.fetch, baseUrl, apiKey }, repoName);
7288
+ ticketPrefix = details.ticketPrefix;
7289
+ }
7290
+ catch {
7291
+ ticketPrefix = undefined;
7292
+ }
7293
+ log("");
7294
+ log(buildInstallBridgeDefaultsSummary({
7295
+ displayName: modernAttempt?.displayName ?? repoName,
7296
+ ...(ticketPrefix ? { ticketPrefix } : {}),
7297
+ isNewProject: Boolean(modernAttempt) || bootstrapExchangeSucceeded,
7298
+ ...(persistedPosture ? { persistedPosture } : {}),
7299
+ claudeCodeSelected: selectedPlatforms.includes("claude-code"),
7300
+ guidance: { written: notesWrittenTargets, unchanged: notesUnchangedTargets },
7301
+ }));
7190
7302
  // ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
7191
7303
  // AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
7192
7304
  // resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
@@ -7303,13 +7415,23 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
7303
7415
  }
7304
7416
  class ModernInstallRecoveryError extends Error {
7305
7417
  }
7418
+ /** The run already printed its own output (for example S6) and stops with exit 1. */
7419
+ class ModernInstallStopped extends Error {
7420
+ }
7421
+ /**
7422
+ * A record that may hold redeemed proof. Presence, not truthiness: an empty-string
7423
+ * field still counts. Such a record is never offered a fresh signup.
7424
+ */
7425
+ function isProtectedModernRecord(record) {
7426
+ return ["fingerprint", "replayToken", "canonical"].some(field => Object.prototype.hasOwnProperty.call(record, field));
7427
+ }
7306
7428
  function modernWriteDeps(deps) {
7307
7429
  return { env: deps.env, homedir: deps.homedir, platform: deps.platform, readFile: deps.readFile,
7308
7430
  mkdir: deps.mkdir, writeFile: deps.writeFile, rename: deps.rename, chmod: deps.chmod, unlink: deps.unlink, open: deps.open };
7309
7431
  }
7310
- async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupEmail, selfServe) {
7432
+ async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupEmail, selfServe, legacyExpired) {
7311
7433
  if (!deps.realpath)
7312
- throw new ModernInstallRecoveryError("Canonical folder resolution is unavailable. Contact installation support.");
7434
+ throw new ModernInstallRecoveryError(`Canonical folder resolution is unavailable. Contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}.`);
7313
7435
  const folder = await deps.realpath(deps.cwd);
7314
7436
  const folderDigest = fingerprintBootstrapFields("bridge-bootstrap-folder-v1", [baseUrl, folder]);
7315
7437
  const signupLocator = `bootstrap-pending:signup:${folderDigest}`;
@@ -7321,30 +7443,50 @@ async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupE
7321
7443
  if (!loaded.ok)
7322
7444
  throw new Error(loaded.error);
7323
7445
  let record = loaded.record;
7324
- const resumed = record !== null;
7446
+ let resumed = record !== null;
7325
7447
  let label = record?.label ?? options.displayName;
7326
- if (label === undefined) {
7448
+ // BAPI-1449: an expired legacy basename signup asks for consent BEFORE any label
7449
+ // question, so its label is derived only after the user accepts.
7450
+ const deferLabelForLegacy = selfServe && record === null && legacyExpired !== undefined;
7451
+ const deriveLabel = async () => {
7327
7452
  if (!deps.isTTY || !deps.promptLine)
7328
7453
  throw new ModernInstallRecoveryError("Modern invite creation requires --display-name in a non-interactive terminal.");
7454
+ // BAPI-1448: a valid folder name is the label, with no question. The prompt
7455
+ // survives only for a folder name the validator rejects.
7329
7456
  let suggestion = "";
7330
7457
  try {
7331
7458
  suggestion = validateBootstrapDisplayName(path.basename(folder));
7332
7459
  }
7333
7460
  catch { /* explicit label required */ }
7334
- const entered = await deps.promptLine(suggestion ? `Project display name [${suggestion}]: ` : "Project display name: ");
7335
- label = validateBootstrapDisplayName(entered || suggestion);
7336
- }
7461
+ if (suggestion)
7462
+ return suggestion;
7463
+ return validateBootstrapDisplayName(await deps.promptLine("Project display name: "));
7464
+ };
7465
+ if (label === undefined && !deferLabelForLegacy)
7466
+ label = await deriveLabel();
7337
7467
  if (selfServe) {
7338
7468
  if (!deps.isTTY || !deps.promptLine)
7339
7469
  throw new ModernInstallRecoveryError("Self-serve email verification requires an interactive terminal.");
7340
- if (!record) {
7341
- const intent = await prepareModernBootstrapSignup(scope, label, signupEmail, () => generateBootstrapKeySecret(deps.randomBytes), writeDeps);
7342
- if (!intent.ok || !intent.record)
7343
- throw new Error(intent.ok ? "Signup proof missing." : intent.error);
7344
- record = intent.record;
7470
+ // At most one fresh signup per invocation, whichever route accepted it.
7471
+ let freshSignupAccepted = false;
7472
+ const generateKeySecret = () => generateBootstrapKeySecret(deps.randomBytes);
7473
+ const generateArchiveSuffix = () => deps.randomBytes(16).toString("hex");
7474
+ // Print the reason, then ask the default-No question. A decline (or EOF, an empty
7475
+ // line, a thrown prompt) prints S6 and stops with the credential store untouched.
7476
+ const askFreshSignup = async (reason) => {
7477
+ deps.log(reason);
7478
+ if (!(await requestCredentialRouteSwitchConfirmation(deps, formatFreshSignupQuestion(signupEmail)))) {
7479
+ deps.log(INSTALL_BRIDGE_FRESH_SIGNUP_DECLINED);
7480
+ throw new ModernInstallStopped();
7481
+ }
7482
+ };
7483
+ // Request a challenge for THIS run's email, then persist its handle and expiry
7484
+ // BEFORE the prompt. The request happens outside the credential-store lock. On
7485
+ // any failure the saved record stays and is never replaced again in this run.
7486
+ const requestAndSaveChallenge = async () => {
7345
7487
  const requested = await requestSelfServeChallenge(deps, baseUrl, signupEmail);
7346
7488
  if (!requested.ok)
7347
- throw new ModernInstallRecoveryError("Signup handle could not be established. Preserve recovery proof; contact installation support before restarting signup.");
7489
+ throw new ModernInstallRecoveryError(formatSelfServeChallengeRequestFailure(requested));
7348
7490
  const saved = await mutateModernBootstrapRecord(scope, writeDeps, current => {
7349
7491
  if (!current)
7350
7492
  throw new ModernInstallRecoveryError("Signup proof missing.");
@@ -7353,24 +7495,161 @@ async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupE
7353
7495
  if (!saved.ok || !saved.record)
7354
7496
  throw new Error(saved.ok ? "Signup proof missing." : saved.error);
7355
7497
  record = saved.record;
7498
+ };
7499
+ // Archive the observed record, install a fresh signup for this run's email, then
7500
+ // (only after that is durable) print S7 and request the new challenge.
7501
+ const replaceWithFreshSignup = async (observed) => {
7502
+ const replaced = await archiveAndReplaceModernBootstrapSignup({ scope, observed, email: signupEmail, generateKeySecret, generateArchiveSuffix }, writeDeps);
7503
+ if (!replaced.ok)
7504
+ throw new ModernInstallRecoveryError(replaced.error);
7505
+ freshSignupAccepted = true;
7506
+ resumed = false;
7507
+ record = replaced.record;
7508
+ deps.log(INSTALL_BRIDGE_FRESH_SIGNUP_STARTING);
7509
+ await requestAndSaveChallenge();
7510
+ };
7511
+ if (!record) {
7512
+ if (legacyExpired) {
7513
+ // The basename-keyed legacy signup is the "no modern record" case: ask, derive
7514
+ // the label from the folder basename, then archive it and install the modern
7515
+ // signup in one write.
7516
+ await askFreshSignup(INSTALL_BRIDGE_RECOVERY_REASON_EXPIRED);
7517
+ label = options.displayName ?? await deriveLabel();
7518
+ const replaced = await archiveExpiredLegacySignupAndInstallModern({ repoName: legacyExpired.repoName, observed: legacyExpired.snapshot, now: deps.now(), scope, label, email: signupEmail, generateKeySecret, generateArchiveSuffix }, writeDeps);
7519
+ if (!replaced.ok)
7520
+ throw new ModernInstallRecoveryError(replaced.error);
7521
+ freshSignupAccepted = true;
7522
+ record = replaced.record;
7523
+ deps.log(INSTALL_BRIDGE_FRESH_SIGNUP_STARTING);
7524
+ await requestAndSaveChallenge();
7525
+ }
7526
+ else {
7527
+ const intent = await prepareModernBootstrapSignup(scope, label, signupEmail, generateKeySecret, writeDeps);
7528
+ if (!intent.ok || !intent.record)
7529
+ throw new Error(intent.ok ? "Signup proof missing." : intent.error);
7530
+ record = intent.record;
7531
+ await requestAndSaveChallenge();
7532
+ }
7533
+ }
7534
+ else if (!isProtectedModernRecord(record)) {
7535
+ // A saved, unresolved signup. Reasons are checked in this order, and the
7536
+ // expiry is read from the stored value only, never from a server reply.
7537
+ // A malformed stored expiry is treated as unexpired.
7538
+ let reason;
7539
+ if (typeof record.signupEmail === "string" && record.signupEmail.trim().toLowerCase() !== signupEmail.trim().toLowerCase()) {
7540
+ reason = INSTALL_BRIDGE_RECOVERY_REASON_DIFFERENT_EMAIL;
7541
+ }
7542
+ else if (!record.challengeHandle || !record.challengeExpiresAt) {
7543
+ reason = INSTALL_BRIDGE_RECOVERY_REASON_NO_CODE;
7544
+ }
7545
+ else if (Date.parse(record.challengeExpiresAt) <= deps.now()) {
7546
+ reason = INSTALL_BRIDGE_RECOVERY_REASON_EXPIRED;
7547
+ }
7548
+ if (reason !== undefined) {
7549
+ await askFreshSignup(reason);
7550
+ await replaceWithFreshSignup(record);
7551
+ }
7356
7552
  }
7553
+ if (!record)
7554
+ throw new ModernInstallRecoveryError("Recovery proof missing.");
7357
7555
  if (record.replayToken && record.fingerprint) {
7358
7556
  token = record.replayToken;
7359
7557
  fingerprint = record.fingerprint;
7360
7558
  scope = makeScope(`bootstrap-pending:invite:${fingerprint}`);
7361
7559
  }
7362
7560
  else {
7561
+ // Only a protected record can reach these two guards unresolved: an unprotected
7562
+ // one was either offered a fresh signup above or already holds a live challenge.
7363
7563
  if (!record.challengeHandle || !record.challengeExpiresAt)
7364
- throw new ModernInstallRecoveryError("The previous signup response is unresolved. Preserve proof and contact installation support before restarting.");
7365
- if (Date.parse(record.challengeExpiresAt) <= deps.now())
7366
- throw new ModernInstallRecoveryError("The saved verification challenge expired. Preserve proof and contact installation support.");
7367
- const code = await promptForVerificationCode(deps, deps.log);
7368
- if (!code)
7369
- throw new ModernInstallRecoveryError("Verification incomplete. Resume the same signup from this folder.");
7370
- const verified = await verifySelfServeChallenge(deps, baseUrl, record.challengeHandle, code);
7371
- if (!verified.ok)
7372
- throw new ModernInstallRecoveryError("Verification did not complete. Preserve proof and resume the same signup.");
7373
- token = verified.token;
7564
+ throw new ModernInstallRecoveryError(`The previous signup response is unresolved. Preserve proof and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL} before restarting.`);
7565
+ if (isProtectedModernRecord(record) && Date.parse(record.challengeExpiresAt) <= deps.now())
7566
+ throw new ModernInstallRecoveryError(`The saved verification challenge expired. Preserve proof and contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}.`);
7567
+ // One pass per challenge. Everything challenge-local resets only when a new
7568
+ // challenge begins; the loop leaves it on a verified code or by throwing.
7569
+ let verifiedToken;
7570
+ for (;;) {
7571
+ const handle = record.challengeHandle;
7572
+ logVerificationCodeLeadIn(deps.log, record.signupEmail ?? signupEmail);
7573
+ let rejections = 0;
7574
+ let consecutiveEmpty = 0;
7575
+ let emptyGuidanceShown = false;
7576
+ // Unknown until a resend in THIS session reports it, including on a resumed
7577
+ // challenge: the first `r` then makes a real request.
7578
+ let resendsRemaining;
7579
+ let capReached = false;
7580
+ // Count a rejection; true when the shared cap was just reached.
7581
+ const countRejection = () => {
7582
+ deps.errorLog(INSTALL_BRIDGE_VERIFICATION_REJECTED);
7583
+ rejections += 1;
7584
+ capReached = rejections >= INSTALL_BRIDGE_MAX_CODE_REJECTIONS;
7585
+ return capReached;
7586
+ };
7587
+ while (!capReached && verifiedToken === undefined) {
7588
+ const prompted = await promptForVerificationCode(deps, deps.log, 3, { allowResend: true });
7589
+ if (prompted.kind === "give-up")
7590
+ throw new ModernInstallRecoveryError(INSTALL_BRIDGE_VERIFICATION_INCOMPLETE);
7591
+ if (prompted.kind === "empty") {
7592
+ consecutiveEmpty += 1;
7593
+ if (consecutiveEmpty >= 2)
7594
+ throw new ModernInstallRecoveryError(INSTALL_BRIDGE_VERIFICATION_INCOMPLETE);
7595
+ if (!emptyGuidanceShown) {
7596
+ deps.log(INSTALL_BRIDGE_EMPTY_CODE_GUIDANCE);
7597
+ emptyGuidanceShown = true;
7598
+ }
7599
+ continue;
7600
+ }
7601
+ consecutiveEmpty = 0;
7602
+ if (prompted.kind === "resend") {
7603
+ if (resendsRemaining === 0) {
7604
+ deps.log(INSTALL_BRIDGE_NO_MORE_CODES);
7605
+ continue;
7606
+ }
7607
+ // The server enforces the cooldown and the resend budget; there is no
7608
+ // client timer or cap. The stored expiry is never touched.
7609
+ const resent = await resendSelfServeChallenge(deps, baseUrl, handle);
7610
+ if (resent.ok) {
7611
+ deps.log(` ${INSTALL_BRIDGE_RESEND_SENT}`);
7612
+ resendsRemaining = resent.resendsRemaining;
7613
+ }
7614
+ else if (resent.category === "cooldown") {
7615
+ deps.log(` ${INSTALL_BRIDGE_RESEND_COOLDOWN}`);
7616
+ }
7617
+ else if (resent.category === "rejected") {
7618
+ // The uniform 401 also covers "no resends left", so the challenge may
7619
+ // still be alive: count it, but do not end the run on its own.
7620
+ countRejection();
7621
+ }
7622
+ else {
7623
+ deps.log(` ${INSTALL_BRIDGE_RESEND_THROTTLED}`);
7624
+ }
7625
+ continue;
7626
+ }
7627
+ const verified = await verifySelfServeChallenge(deps, baseUrl, handle, prompted.code);
7628
+ if (verified.ok) {
7629
+ // BAPI-1173: only an EXPLICIT false prints.
7630
+ if (verified.trialGranted === false)
7631
+ deps.log(` ${INSTALL_BRIDGE_TRIAL_ALREADY_USED}`);
7632
+ verifiedToken = verified.token;
7633
+ break;
7634
+ }
7635
+ if (verified.category === "rejected") {
7636
+ if (!countRejection() && resendsRemaining !== 0)
7637
+ deps.log(INSTALL_BRIDGE_REJECTED_RESEND_HINT);
7638
+ continue;
7639
+ }
7640
+ // Every other category ends the run and keeps the saved record.
7641
+ throw new ModernInstallRecoveryError(formatSelfServeVerifyFailure(verified));
7642
+ }
7643
+ if (verifiedToken !== undefined)
7644
+ break;
7645
+ // Rejection cap: offer one fresh signup (S5), unless one was already taken.
7646
+ if (freshSignupAccepted) {
7647
+ throw new ModernInstallRecoveryError(`${INSTALL_BRIDGE_VERIFICATION_GIVING_UP}\n\nRun it again from this same folder.`);
7648
+ }
7649
+ await askFreshSignup(INSTALL_BRIDGE_RECOVERY_REASON_CAP);
7650
+ await replaceWithFreshSignup(record);
7651
+ }
7652
+ token = verifiedToken;
7374
7653
  fingerprint = fingerprintBootstrapInvite(token, baseUrl);
7375
7654
  const inviteScope = makeScope(`bootstrap-pending:invite:${fingerprint}`);
7376
7655
  const transitioned = await transitionModernBootstrapSignup(makeScope(signupLocator), inviteScope, fingerprint, token, record.challengeHandle, writeDeps);
@@ -7406,7 +7685,7 @@ async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupE
7406
7685
  if (!exchange.ok && exchange.message.includes("Creation outcome is unknown"))
7407
7686
  throw new ModernInstallRecoveryError("Creation outcome is unknown. Preserve recovery proof and rerun the same attempt from the original folder and invite/signup.");
7408
7687
  if (!exchange.ok || !exchange.displayName)
7409
- throw new ModernInstallRecoveryError("Creation or replay did not complete. Preserve proof and rerun from this folder with the same invite/signup; verify server compatibility or contact installation support.");
7688
+ throw new ModernInstallRecoveryError(`Creation or replay did not complete. Preserve proof and rerun from this folder with the same invite/signup; verify server compatibility or contact Bridge support at ${BRIDGE_SUPPORT_EMAIL}.`);
7410
7689
  const checkpoint = await checkpointModernBootstrapIdentity(scope, record, exchange.repoName, exchange.displayName, writeDeps);
7411
7690
  if (!checkpoint.ok || !checkpoint.record)
7412
7691
  throw new Error(checkpoint.ok ? "Checkpoint missing." : checkpoint.error);