@bridge_gpt/mcp-server 0.2.67 → 0.2.69

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 (127) hide show
  1. package/README.md +272 -51
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +1362 -0
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-settings.js +102 -2
  9. package/build/cli-help.js +27 -0
  10. package/build/codex-home.js +9 -1
  11. package/build/codex-launcher-pin.js +598 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +9 -9
  15. package/build/conductor/bridge-api-client.js +714 -0
  16. package/build/conductor/command-drift-production.js +92 -0
  17. package/build/conductor/command-drift.js +89 -0
  18. package/build/conductor/doctor.js +69 -26
  19. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  20. package/build/conductor/epic-implementer-cli.js +76 -22
  21. package/build/conductor/gate-discovery.js +890 -0
  22. package/build/conductor/install-doctor.js +15 -3
  23. package/build/conductor/json-output.js +63 -0
  24. package/build/conductor/readiness-cli.js +95 -10
  25. package/build/conductor/readiness-sections.js +369 -3
  26. package/build/conductor/readiness.js +211 -8
  27. package/build/conductor/recovery-cli.js +110 -4
  28. package/build/conductor/recovery-operations.js +99 -1
  29. package/build/connect-bitbucket-api.js +41 -6
  30. package/build/connect-bitbucket.js +106 -33
  31. package/build/connect-github-api.js +33 -14
  32. package/build/credential-store.js +207 -47
  33. package/build/docs.generated.js +57 -3
  34. package/build/doctor.js +198 -4
  35. package/build/drive-epic.js +45 -8
  36. package/build/epic-coherence-input.test-fixtures.js +4 -3
  37. package/build/epic-implementer-bin.js +1369 -25
  38. package/build/epic-workarounds-path.js +39 -0
  39. package/build/executor/cli.js +30 -0
  40. package/build/executor/completion-contract.js +20 -5
  41. package/build/executor/deps.js +4 -1
  42. package/build/executor/gate-qualification-job.js +499 -0
  43. package/build/executor/guarded-fast-forward-push.js +129 -0
  44. package/build/executor/http-client.js +43 -4
  45. package/build/executor/integration-job.js +122 -8
  46. package/build/executor/job-errors.js +8 -1
  47. package/build/executor/job-runner.js +589 -47
  48. package/build/executor/likely-tests.js +290 -0
  49. package/build/executor/live-worker-registry.js +1 -1
  50. package/build/executor/observation.js +31 -2
  51. package/build/executor/packaged-recipe.js +106 -0
  52. package/build/executor/permissions.js +115 -28
  53. package/build/executor/platform.js +1 -1
  54. package/build/executor/preflight.js +61 -12
  55. package/build/executor/process-containment.js +36 -12
  56. package/build/executor/process.js +15 -7
  57. package/build/executor/prompt-spec.js +33 -1
  58. package/build/executor/recently-failed-tests-file.js +71 -0
  59. package/build/executor/reconcile-overlap-job.js +41 -23
  60. package/build/executor/required-verification.js +1 -1
  61. package/build/executor/required-work-ledger.js +105 -17
  62. package/build/executor/retirement.js +69 -0
  63. package/build/executor/runner.js +114 -11
  64. package/build/executor/test-clock.js +15 -0
  65. package/build/executor/types.js +32 -0
  66. package/build/executor/viewer-tabs.js +58 -8
  67. package/build/executor/worker-command.js +23 -7
  68. package/build/executor/worker-diagnostics.js +37 -0
  69. package/build/executor/worker-finalization.js +31 -5
  70. package/build/executor/worker-guard-hook.js +179 -25
  71. package/build/executor/worker-log.js +4 -3
  72. package/build/executor/worker-verification-guidance.js +37 -0
  73. package/build/executor/worktree.js +88 -11
  74. package/build/first-build-advisory.js +97 -0
  75. package/build/generated-assets-advisory.js +14 -0
  76. package/build/generated-assets-catalog.js +66 -0
  77. package/build/generated-assets-context.js +46 -0
  78. package/build/generated-assets-dependencies.js +26 -0
  79. package/build/generated-assets-doctor.js +50 -0
  80. package/build/generated-assets-health.js +65 -0
  81. package/build/generated-assets-host.js +17 -0
  82. package/build/generated-assets-inventory.js +17 -0
  83. package/build/generated-assets-paths.js +60 -0
  84. package/build/generated-assets-publisher.js +133 -0
  85. package/build/generated-assets-remedy.js +25 -0
  86. package/build/generated-assets-repair.js +72 -0
  87. package/build/generated-assets-session.js +71 -0
  88. package/build/generated-assets-test-support.js +35 -0
  89. package/build/index.js +1109 -695
  90. package/build/init.js +54 -177
  91. package/build/install-bridge-conductor-gate.js +1372 -0
  92. package/build/install-bridge-conductor.js +289 -36
  93. package/build/install-bridge.js +722 -252
  94. package/build/launcher-config-inspection.js +184 -19
  95. package/build/mcp-host-targets.js +4 -0
  96. package/build/mcp-identity.js +7 -0
  97. package/build/mcp-install-state.js +50 -4
  98. package/build/merge-workflow-install.js +3 -4
  99. package/build/pipeline-utils.js +6 -2
  100. package/build/pipelines.generated.js +22 -142
  101. package/build/plan-execution-ledger.js +2 -32
  102. package/build/plane/manifest.js +16 -1
  103. package/build/plane/preflight.js +54 -1
  104. package/build/printed-remedy-commands.js +16 -1
  105. package/build/readme.generated.js +1 -1
  106. package/build/repair-assets.js +93 -0
  107. package/build/run-unit-tests-launcher.js +130 -49
  108. package/build/secret-redaction.js +33 -0
  109. package/build/session-upgrade-notice.js +86 -0
  110. package/build/setup-epic.js +108 -3
  111. package/build/start-tickets-prereqs.js +70 -16
  112. package/build/start-tickets.js +44 -5
  113. package/build/tool-call-advisory.js +162 -0
  114. package/build/tool-surface-gating.js +32 -57
  115. package/build/update-check.js +97 -16
  116. package/build/update-status.js +61 -71
  117. package/build/upgrade-advice.js +15 -11
  118. package/build/upgrade-cli.js +398 -17
  119. package/build/version.generated.js +3 -3
  120. package/build/worker-guard-hook-bin.js +3 -2
  121. package/build/worker-guard-hook-main.js +1 -1
  122. package/build/worktree-core.js +15 -2
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  124. package/docs/EPIC-IMPLEMENTER.md +68 -2
  125. package/package.json +3 -3
  126. package/pipelines/greenfield-setup.json +4 -128
  127. package/pipelines/implement-ticket.json +3 -2
@@ -1,3 +1,6 @@
1
+ import { validateBootstrapDisplayName, validateBootstrapServerIdentity } from "./bootstrap-project-identity.js";
2
+ import { lookupModernBootstrapRecord, mutateModernBootstrapRecord, prepareModernBootstrapInvite, prepareModernBootstrapSignup, transitionModernBootstrapSignup, lookupLegacyBootstrapInvite, checkpointModernBootstrapIdentity, promoteModernBootstrapCredential } from "./credential-store.js";
3
+ import { collectFirstBuildAdvisory, formatFirstBuildAdvisory } from "./first-build-advisory.js";
1
4
  /**
2
5
  * install-bridge — the one-command Bridge API project bootstrap (BAPI-429).
3
6
  *
@@ -64,9 +67,9 @@
64
67
  * (BAPI-606) redeems a pre-issued invite; `self-serve` (BAPI-618) mints one from an
65
68
  * email. Both then feed the SAME redemption protocol. Selection is pure and
66
69
  * deterministic from flags/env, except that a BARE interactive run is asked ONE
67
- * question (BAPI-626/708): `Welcome to Bridge - do you have a token yet?`, answered
68
- * `1` (yes) or `2` (no). Answering `2` is the self-serve route, which a first-time
69
- * 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,
70
73
  * CREDENTIAL-AGNOSTIC token prompt and the route is then inferred from the pasted
71
74
  * value's SHAPE — the user is never asked to pre-classify their own credential,
72
75
  * because the shape checks below do that with no probe request. Either need-key
@@ -137,7 +140,7 @@
137
140
  * 0600, fsync'd pending record as the `key_secret`, purely so the signup can be
138
141
  * replayed, and it is discarded when that record is promoted.
139
142
  */
140
- import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open } from "fs/promises";
143
+ import { readFile, writeFile, mkdir, stat, rename, chmod, unlink, open, realpath } from "fs/promises";
141
144
  import { spawn } from "child_process";
142
145
  import { randomBytes as cryptoRandomBytes, createHash } from "crypto";
143
146
  import os from "os";
@@ -152,7 +155,7 @@ import { MCP_HOST_TARGETS, HOST_PLATFORM_ORDER, allHostTargets, agentForPlatform
152
155
  import { provisionHostTarget, createDefaultVendorProcessDeps, inspectBridgeApiProfileToken, } from "./mcp-host-config.js";
153
156
  import { adaptBridgeEntryForHostTarget, } from "./mcp-host-entry-adapter.js";
154
157
  import { MCP_SERVER_NAME, MCP_PACKAGE_NAME, resolveRegistrationKey, } from "./mcp-identity.js";
155
- import { DUPLICATE_REGISTRATION_GUIDANCE } from "./launcher-config-inspection.js";
158
+ import { DUPLICATE_REGISTRATION_GUIDANCE, discoverClaudeCodeEffectivePin } from "./launcher-config-inspection.js";
156
159
  import { codexHome, configureBridgeCodexProfile, inspectBridgeCodexProfile } from "./codex-bridge-profile.js";
157
160
  import { recordInstalledConductorCapability, recordInstalledExecutorServiceUnit, recordInstalledProjectArtifact, writeMcpInstallState, } from "./mcp-install-state.js";
158
161
  // BAPI-1207: opt-in cross-platform agent notes (AGENTS.md / CLAUDE.md).
@@ -168,9 +171,13 @@ import { executorLaunchdLabelForId, executorSystemdUnitNameForId, executorLaunch
168
171
  import { collectConductorNativeLedgerSafe, } from "./conductor/doctor.js";
169
172
  import { installConductorGitHooks, } from "./conductor/git-hooks.js";
170
173
  import { runInstallBridgeConductorCli, } from "./install-bridge-conductor.js";
174
+ import { createQualificationExecutorRunner, } from "./install-bridge-conductor-gate.js";
175
+ import { createNodeGateDiscoveryFs } from "./conductor/gate-discovery.js";
176
+ import { runExecutorCli } from "./executor/cli.js";
177
+ import { runExecutor } from "./executor/runner.js";
171
178
  // BAPI-1136: the merge-workflow generate-and-PR installer. Domain logic lives in
172
179
  // its own module; this file supplies the parser and the process seams.
173
- import { runMergeWorkflowInstall, } from "./merge-workflow-install.js";
180
+ import { parseGithubRemote, runMergeWorkflowInstall, } from "./merge-workflow-install.js";
174
181
  import { MERGE_WORKFLOW_CODEOWNER_INSTRUCTION, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
175
182
  import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
176
183
  import { runConductorInstallDoctor, EPIC_IMPLEMENTER_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
@@ -180,15 +187,17 @@ import { claudeReviewWorkflowPath, writeClaudeReviewWorkflow, } from "./claude-r
180
187
  // doctor's optional drift seam below.
181
188
  import { probeClaudeReviewWorkflowDrift, resolveCurrentBranch, resolveRepositoryDefaultBranch, } from "./claude-review-workflow-drift-probe.js";
182
189
  import { runSetupEpicCli } from "./setup-epic.js";
190
+ import { runBridgePermissionsInstallStep, runBridgePermissionsRemoval, } from "./bridge-permissions.js";
183
191
  import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
184
192
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
185
193
  import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, prepareSelfServeBootstrapChallenge as prepareSelfServeBootstrapChallengeCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
186
194
  // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
187
195
  // API primitives verbatim — no duplicated polling, browser, or picker logic here.
188
- import { fetchGithubConfigurationState } from "./connect-github-api.js";
196
+ import { fetchGithubConfigurationDetails, fetchGithubConfigurationState, } from "./connect-github-api.js";
189
197
  import { createDefaultConnectGithubDeps, runGithubConnectionFlow } from "./connect-github.js";
190
198
  import { fetchBitbucketConfigurationState } from "./connect-bitbucket-api.js";
191
199
  import { createDefaultConnectBitbucketDeps, runBitbucketConnectionFlow } from "./connect-bitbucket.js";
200
+ import { defaultGitRemoteRunner, resolveBitbucketWorkspace, } from "./bitbucket-workspace-remote.js";
192
201
  import { resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, } from "./agent-registry.js";
193
202
  import { buildGenericAgentShellCommand, getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, materializeWorkerLaunchCommand, MAX_TERMINAL_COMMAND_BYTES, validateResolvedModelAliasForAgent, } from "./start-tickets.js";
194
203
  import { defaultNpmEntryFileExists, resolveNpmEntry } from "./windows-npm-entry.js";
@@ -265,7 +274,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
265
274
  "'Needs setup' — and each item in them carries display_name, description, how_to_use, and (in the " +
266
275
  "'Needs setup' section only) availability_text. Render each item's display_name, its description, " +
267
276
  "and its how_to_use verbatim when that value is a non-empty string; render availability_text " +
268
- "verbatim as the setup guidance for a 'Needs setup' item, and show NO availability caveat, " +
277
+ "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, " +
269
278
  "asterisk, or footnote on an 'Available now' item. An item's `kind` ('tool' or 'workflow') is " +
270
279
  "contract metadata only — render both kinds identically, with no label, icon, or separate list, " +
271
280
  "and never branch on it. Keep an empty section visible with a short neutral line rather than " +
@@ -294,7 +303,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
294
303
  "short, plain-language sentence covering what is happening and why it matters to the person running " +
295
304
  "this install, rather than in internal shorthand. Narration you compose yourself must never name an " +
296
305
  "internal stage number, this command's contract version, a raw internal MCP tool identifier (like " +
297
- "get_install_manifest, apply_install_manifest, or invite_member), or a manifest field key (like " +
306
+ "get_install_manifest or apply_install_manifest), or a manifest field key (like " +
298
307
  "command_contract_version, next_step, or configured) — describe what is actually happening instead. " +
299
308
  "This restriction is scoped to narration you author: it does not apply to a user-invocable command " +
300
309
  "name you must give the human verbatim (/install-bridge, /learn-repository, connect-github, " +
@@ -308,11 +317,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
308
317
  "duplicate. This silence applies ONLY to success and ONLY in the install-spawn context: a Stage 6 " +
309
318
  "FAILURE is still reported loudly and in full, and in JOIN MODE or a direct/manual /install-bridge " +
310
319
  "invocation Stage 6 reports its success exactly as it does today. " +
311
- "The install-spawn skip set is exactly Stage 8 and Stage 9. The command-owned Stage 11 " +
312
- "(invite teammates) is NOT in that skip set: it is independently gated and may run only when the " +
313
- "caller's role is admin AND customer_type is b2b AND an interactive response is available; " +
314
- "otherwise it is skipped silently. If Stage 11 mints a teammate key, show that plaintext key " +
315
- "exactly once and never repeat it in any summary, retry, or diagnostic. " +
320
+ "The install-spawn skip set is exactly Stage 8 and Stage 9. " +
316
321
  "Do NOT ask any indexing question, call parse_repository, mention /parse-repository, or claim " +
317
322
  "indexing has started or is pending — indexing is decided entirely by the server-side readiness " +
318
323
  "funnel with no visibility from this session, so say nothing about it at all. " +
@@ -385,9 +390,8 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
385
390
  "the next step the server returned — when it returns one.",
386
391
  "If the project is already configured it instead",
387
392
  "joins you to it without proposing or applying any changes and just shows the",
388
- "concise capability report plus that same conditional next step (a b2b admin is",
389
- "additionally offered a teammate-invite",
390
- "step). So not every run applies config fields. Indexing is never asked about —",
393
+ "concise capability report plus that same conditional next step.",
394
+ "So not every run applies config fields. Indexing is never asked about —",
391
395
  "it starts automatically once the repository reaches full parse readiness.",
392
396
  "",
393
397
  "Your API key is written to a project MCP config only when that file is safe: a",
@@ -397,17 +401,15 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
397
401
  "resolves your key from the credential store at runtime. A config file that",
398
402
  "cannot be parsed is left untouched, with manual-merge instructions printed.",
399
403
  "",
400
- "Run it bare — `install` with no flags — in a terminal and it asks",
401
- `\`${INSTALL_BRIDGE_KEY_SELECTOR_PROMPT}\` first, with two numbered choices:`,
402
- ...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
403
- "There is NO default — pressing Enter selects nothing; you must type 1 or 2 (one",
404
- "blank or invalid answer re-prompts once, then exits with guidance). Answering 1",
405
- "prompts for an email and creates a brand-new Bridge workspace for you (the",
406
- "self-serve flow). Answering 2 opens a single hidden token prompt: paste whatever",
407
- "you were given and its SHAPE picks the route — a bootstrap invite (bapi_inv_…)",
408
- "is redeemed to CREATE a new project and mint your first admin key, while a",
409
- "Bridge API key connects to the project that already exists. You are",
410
- "never asked which kind of token you hold. That question is asked ONLY for a",
404
+ "Run it bare — `install` with no flags — in a terminal and it asks one question:",
405
+ `\`${INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT.trim()}\``,
406
+ "Type your email to create a brand-new Bridge workspace for you (the self-serve",
407
+ "flow). Press Enter if you already have a token: a single hidden token prompt",
408
+ "opens, and whatever you paste has its SHAPE pick the route — a bootstrap invite",
409
+ "(bapi_inv_…) is redeemed to CREATE a new project and mint your first admin key,",
410
+ "while a Bridge API key connects to the project that already exists. One",
411
+ "malformed answer re-prompts once, then exits with guidance. You are",
412
+ "never asked which kind of token you hold. The question is asked ONLY for a",
411
413
  "bare interactive run: passing ANY flag, setting BAPI_API_KEY, or running without an interactive",
412
414
  "terminal keeps the existing deterministic behavior and no prompt.",
413
415
  "",
@@ -443,7 +445,10 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
443
445
  " DECLARES the key route, so that switch is confirmed on a",
444
446
  " terminal; the bare run's hidden token prompt infers instead",
445
447
  " and only announces. NEVER printed or logged.",
446
- " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
448
+ " --display-name <label> Friendly creation label; required for non-TTY modern invites.",
449
+ " Do not combine with --repo or BAPI_REPO_NAME. Existing keys use canonical selectors.",
450
+ " Self-serve verification still requires an interactive terminal.",
451
+ " --repo <name> Canonical selector for reconnect or explicit legacy creation. --repo and BAPI_REPO_NAME still take",
447
452
  " priority and short-circuit before any network call. When",
448
453
  " neither is set, a compatible server resolves the unique",
449
454
  " repository from your existing API key automatically. If the",
@@ -520,9 +525,11 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
520
525
  " --tools claude-code,codex are accepted. On an",
521
526
  " interactive terminal WITHOUT this flag you get a",
522
527
  " numbered picker: enter one or more tool numbers,",
523
- " comma-separated (e.g. 1,3), and press Enter once —",
524
- " no tool is pre-selected (not even Claude Code) and",
525
- " you must select at least one. A non-interactive",
528
+ " comma-separated (e.g. 1,3), and press Enter once.",
529
+ " Tools Bridge detects on this machine are pre-ticked:",
530
+ " a bare Enter accepts them, and typed numbers",
531
+ " replace them. With none detected you must select",
532
+ " at least one. A non-interactive",
526
533
  " (non-TTY) run without",
527
534
  " --tools writes the legacy automatic set (Claude",
528
535
  " Code plus any detected Cursor / Copilot VS Code)",
@@ -551,6 +558,8 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
551
558
  " session — it always wins, and there is NO Claude",
552
559
  " default. Without it the launch tool is derived",
553
560
  " from your selection: Claude Code opens `claude`.",
561
+ " With more than one launchable tool selected, the",
562
+ " first one is used and no question is asked.",
554
563
  " Cursor is NOT auto-opened (its cursor-agent CLI",
555
564
  " first-run workspace-trust prompt collapses a spawned",
556
565
  " session); selecting Cursor writes .cursor/mcp.json",
@@ -561,6 +570,13 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
561
570
  " --agent cursor-agent still force-spawns it. Codex",
562
571
  " is accepted only for manual continuation: neither",
563
572
  " this bootstrap nor start-tickets launches it yet.",
573
+ " --remove-bridge-permissions Remove ONLY the permission rules and autoMode",
574
+ " entries Bridge wrote for /drive-epic-run",
575
+ " (recorded in .bridge/bridge-permissions.json)",
576
+ " from .claude/settings.local.json and",
577
+ " ~/.claude/settings.json. Must be used alone.",
578
+ " Entries you added yourself stay. Runs nothing",
579
+ " else: no onboarding, no credential, no network.",
564
580
  " -h, --help Show this help.",
565
581
  "",
566
582
  "Subcommands:",
@@ -642,6 +658,7 @@ export function parseInstallBridgeArgs(argv) {
642
658
  }
643
659
  let apiKey;
644
660
  let repo;
661
+ let displayName;
645
662
  let force = false;
646
663
  let dryRun = false;
647
664
  // Undefined until `--agent` is explicitly supplied — install-bridge never falls
@@ -667,8 +684,32 @@ export function parseInstallBridgeArgs(argv) {
667
684
  }
668
685
  return { value: argv[i + 1], nextIndex: i + 1 };
669
686
  };
687
+ // BAPI-1398: the removal flag selects a different, narrow action, so it is
688
+ // accepted only on its own — combining it with an install flag is ambiguous.
689
+ if (argv.includes("--remove-bridge-permissions")) {
690
+ if (argv.length !== 1) {
691
+ return {
692
+ status: "error",
693
+ message: "--remove-bridge-permissions must be used alone; it removes Bridge's permission rules and does nothing else.",
694
+ };
695
+ }
696
+ return { status: "ok", options: { force: false, dryRun: false, removeBridgePermissions: true } };
697
+ }
670
698
  for (let i = 0; i < argv.length; i++) {
671
699
  const arg = argv[i];
700
+ if (arg === "--display-name" || arg.startsWith("--display-name=")) {
701
+ const result = readValue(arg, "--display-name", i);
702
+ if ("error" in result)
703
+ return { status: "error", message: result.error };
704
+ try {
705
+ displayName = validateBootstrapDisplayName(result.value);
706
+ }
707
+ catch {
708
+ return { status: "error", message: "--display-name requires a safe label of 1–255 Unicode characters." };
709
+ }
710
+ i = result.nextIndex;
711
+ continue;
712
+ }
672
713
  if (arg === "--force") {
673
714
  force = true;
674
715
  continue;
@@ -797,7 +838,7 @@ export function parseInstallBridgeArgs(argv) {
797
838
  }
798
839
  return {
799
840
  status: "ok",
800
- options: { apiKey, repo, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email, tools, codexProfile },
841
+ options: { apiKey, repo, displayName, force, dryRun, agentName, invite, inviteMode: inviteSupplied, email, tools, codexProfile },
801
842
  };
802
843
  }
803
844
  /**
@@ -918,6 +959,8 @@ export async function offerGithubConnection(repoName, baseUrl, deps, log, sessio
918
959
  // No prompt surface → no offer. Never assume consent on a non-interactive run.
919
960
  if (!deps.isTTY || !deps.promptLine)
920
961
  return;
962
+ let attempted = false;
963
+ const fallbackContext = " This attempt did not establish a successful Bridge GitHub link. Bitbucket may be offered next as an optional alternative; you can decline it and retry GitHub later.";
921
964
  try {
922
965
  const credDeps = {
923
966
  env: deps.env,
@@ -961,6 +1004,7 @@ export async function offerGithubConnection(repoName, baseUrl, deps, log, sessio
961
1004
  if (answer !== "y" && answer !== "yes")
962
1005
  return; // declining is a normal outcome
963
1006
  const connectDeps = createDefaultConnectGithubDeps();
1007
+ attempted = true;
964
1008
  const code = await runGithubConnectionFlow(connectDeps, api, repoName);
965
1009
  if (code === 0) {
966
1010
  // BAPI-1171: only a CONFIRMED success suppresses the Bitbucket offer below —
@@ -968,12 +1012,15 @@ export async function offerGithubConnection(repoName, baseUrl, deps, log, sessio
968
1012
  session.vcsConnected = true;
969
1013
  }
970
1014
  else {
1015
+ log(fallbackContext);
971
1016
  log(" note: GitHub was not connected. Your install is complete — connect GitHub later with " +
972
1017
  `'npx -y ${MCP_PACKAGE_NAME}@latest connect-github --repo ${repoName}'.`);
973
1018
  }
974
1019
  }
975
1020
  catch {
976
1021
  // The install is already durable; a failure here is never fatal to it.
1022
+ if (attempted)
1023
+ log(fallbackContext);
977
1024
  log(" note: the GitHub connection offer could not run. Your install is complete — connect " +
978
1025
  `GitHub later with 'npx -y ${MCP_PACKAGE_NAME}@latest connect-github --repo ${repoName}'.`);
979
1026
  }
@@ -995,7 +1042,10 @@ export const INSTALL_BRIDGE_BITBUCKET_OFFER_CONTEXT = [
995
1042
  // BAPI-1247: name the destination precisely. "Admin panel" sent people looking for a
996
1043
  // page that does not exist under that name — the app lives under the workspace's own
997
1044
  // settings, and saying so is the difference between a 20-second step and a hunt.
998
- ' You finish by pasting a printed code into Workspace settings → Apps → "Connect Bitbucket to Bridge API".',
1045
+ // BAPI-1401: say up front that there are two steps. Bitbucket's install screen gives no
1046
+ // sign that a second one exists.
1047
+ " There are two steps: (1) install the app, then (2) approve the request on the app's page",
1048
+ ' under Workspace settings → Forge Apps → "Connect Bitbucket to Bridge API".',
999
1049
  ` You can do this later instead: ${INSTALL_BRIDGE_CONNECT_BITBUCKET_COMMAND}`,
1000
1050
  ];
1001
1051
  /** Default-No Bitbucket offer prompt. Mirrors {@link INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT}. */
@@ -1060,11 +1110,13 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1060
1110
  if (answer !== "y" && answer !== "yes")
1061
1111
  return; // declining is a normal outcome
1062
1112
  const connectDeps = createDefaultConnectBitbucketDeps();
1113
+ // BAPI-1401: the workspace comes from the project's origin remote, the same way the
1114
+ // standalone command infers it. With one, the flow mints an approval-mode request
1115
+ // (and prints the direct link when the server has a template); without one it falls
1116
+ // back to the code flow and its textual navigation path.
1117
+ const workspace = await resolveBitbucketWorkspace(undefined, deps.cwd, deps.runGit ?? defaultGitRemoteRunner, log);
1063
1118
  const result = await runBitbucketConnectionFlow(connectDeps, api, repoName, {
1064
- // The install path has no workspace slug to give: a workspace is only discoverable
1065
- // AFTER redemption stages candidates, which is later than this. The connection flow
1066
- // falls back to its textual navigation path rather than guessing a link.
1067
- workspace: undefined,
1119
+ workspace,
1068
1120
  // The Step 4b prompt just above IS the approval for the first mint. Asking again
1069
1121
  // inside the flow would be a second confirmation for one decision.
1070
1122
  initialMintApproved: true,
@@ -1092,12 +1144,6 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1092
1144
  `Bitbucket later with 'npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo ${repoName}'.`);
1093
1145
  }
1094
1146
  }
1095
- /**
1096
- * The exact agent-notes consent-prompt prefix, shared with tests so the copy
1097
- * cannot drift. Mirrors {@link INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX}: the
1098
- * prefix is pinned, the platform tail varies with the selection.
1099
- */
1100
- export const INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX = "Write guidance so ";
1101
1147
  /**
1102
1148
  * Describe the selected notes platforms for the offer copy: a naturally joined
1103
1149
  * subject and whether it is plural (so a verb can agree). `platformLabels` are
@@ -1120,64 +1166,30 @@ function joinLabelsWithAnd(labels) {
1120
1166
  return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1121
1167
  }
1122
1168
  /**
1123
- * The default-No agent-notes consent prompt (BAPI-1207; payoff-led, platform-named
1124
- * copy per the BAPI-1239 install-issue #4 follow-up). Leads with WHY — teaching
1125
- * the agent how to use Bridge's MCP tools — and NAMES the selected platform(s)
1126
- * rather than the old generic "cross-platform" phrasing.
1127
- */
1128
- export function buildAgentNotesPrompt(platformLabels) {
1129
- const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1130
- return `${INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX}${subject} know${plural ? "" : "s"} how to use the Bridge MCP? [y/N]: `;
1131
- }
1132
- /**
1133
- * The offer-context lines printed immediately before the agent-notes prompt
1134
- * (BAPI-1207). Leads with the payoff — teaching the named platform(s) WHEN to
1135
- * reach for Bridge's MCP tools — then states the marker-scoped, non-destructive
1136
- * contract as a secondary line, mirroring the GitHub/Bitbucket offers' "explain
1137
- * before asking" convention.
1138
- */
1139
- export function buildAgentNotesOfferContext(targets, platformLabels) {
1140
- const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1141
- return [
1142
- "",
1143
- `Step 4c — optional: Bridge MCP guidance for ${subject}.`,
1144
- ` Write guidance so ${subject} know${plural ? "" : "s"} when to reach for Bridge's MCP tools`,
1145
- " during a task — for project standards, a second opinion, a council, or a ticket review.",
1146
- ` It writes a Bridge-owned region to ${targets.join(" and ")}; only that marked region is`,
1147
- " created or refreshed — any existing content, inside or outside it, is otherwise preserved.",
1148
- ];
1149
- }
1150
- /**
1151
- * Offer to create or refresh the cross-platform agent notes, if the selected
1152
- * platforms resolve to at least one writable target (BAPI-1207).
1169
+ * Decide whether to create or refresh the cross-platform agent notes, if the
1170
+ * selected platforms resolve to at least one writable target (BAPI-1207).
1171
+ *
1172
+ * BAPI-1448: an interactive run consents by default — the question is gone, with
1173
+ * the product sign-off recorded in the install notes. The write is still bounded to
1174
+ * Bridge's marked region (see {@link writeAgentNotes}) and reported afterwards with
1175
+ * a removal hint. A non-interactive run keeps `headless-skip`: it never writes
1176
+ * guidance on its own.
1153
1177
  *
1154
- * Entirely best-effort and non-destructive, mirroring
1155
- * {@link offerGithubConnection}: by the time this runs the install itself is
1156
- * already complete and durable, so nothing here may fail the run. This
1157
- * function only resolves the CONSENT decision — the caller performs the
1158
- * actual write via {@link writeAgentNotes}, so a `--dry-run` (which never
1159
- * reaches this call) and the live path share exactly one write path.
1178
+ * Entirely best-effort and non-destructive: by the time this runs the install
1179
+ * itself is already complete and durable, so nothing here may fail the run. This
1180
+ * function only resolves the CONSENT decision — the caller performs the actual
1181
+ * write via {@link writeAgentNotes}, so a `--dry-run` (which never reaches this
1182
+ * call) and the live path share exactly one write path.
1160
1183
  */
1161
- export async function offerAgentGuidanceNotes(deps, log, targets, platformLabels) {
1184
+ export function offerAgentGuidanceNotes(deps, targets) {
1162
1185
  if (targets.length === 0)
1163
1186
  return { kind: "not-applicable" };
1164
- // No prompt surface → no offer. Never assume consent on a non-interactive run.
1165
- if (!deps.isTTY || !deps.promptLine)
1187
+ if (!deps.isTTY)
1166
1188
  return { kind: "headless-skip" };
1167
- try {
1168
- for (const line of buildAgentNotesOfferContext(targets, platformLabels))
1169
- log(line);
1170
- const answer = (await deps.promptLine(buildAgentNotesPrompt(platformLabels))).trim().toLowerCase();
1171
- // Only an explicit yes proceeds — blank input, EOF, "n", and anything malformed
1172
- // all decline without writing.
1173
- return answer === "y" || answer === "yes" ? { kind: "consented" } : { kind: "declined" };
1174
- }
1175
- catch {
1176
- // The install is already durable; a failure here is never fatal to it.
1177
- log(" note: the agent-notes offer could not run. No notes files were written.");
1178
- return { kind: "declined" };
1179
- }
1189
+ return { kind: "consented" };
1180
1190
  }
1191
+ /** The one-line removal hint printed after guidance was actually written (BAPI-1448). */
1192
+ export const INSTALL_BRIDGE_AGENT_NOTES_REMOVAL_HINT = "To remove it, delete the lines from <!-- BEGIN BRIDGE MCP GUIDANCE --> to <!-- END BRIDGE MCP GUIDANCE -->.";
1181
1193
  /**
1182
1194
  * One secret-free summary line for the final install output (BAPI-1207). Never
1183
1195
  * reveals guidance content, file bytes, or a credential — only which files
@@ -1192,11 +1204,13 @@ export function buildAgentNotesSummaryLine(offer, result, writeFailed, unsupport
1192
1204
  return unsupportedPlatforms.length > 0 ? `Agent notes: not written${unsupportedNote}.` : undefined;
1193
1205
  }
1194
1206
  if (offer.kind === "headless-skip") {
1195
- return `Agent notes: skipped (non-interactive session)${unsupportedNote}.`;
1207
+ return `Agent notes: skipped (non-interactive session or submission provenance unavailable)${unsupportedNote}.`;
1196
1208
  }
1197
1209
  if (offer.kind === "declined") {
1198
- return `Agent notes: skipped (declined)${unsupportedNote}.`;
1210
+ return `Agent notes: skipped (declined or no submitted consent)${unsupportedNote}.`;
1199
1211
  }
1212
+ if (offer.kind === "prompt-error")
1213
+ return undefined;
1200
1214
  if (writeFailed || !result) {
1201
1215
  return `Agent notes: could not be written (unexpected error)${unsupportedNote}.`;
1202
1216
  }
@@ -1212,6 +1226,47 @@ export function buildAgentNotesSummaryLine(offer, result, writeFailed, unsupport
1212
1226
  parts.push(`failed for ${failed.join(", ")}`);
1213
1227
  return `Agent notes: ${parts.join("; ")}${unsupportedNote}.`;
1214
1228
  }
1229
+ const OPTIMIZATION_POSTURE_SUMMARY_LABELS = {
1230
+ quality: "Quality",
1231
+ speed: "Speed",
1232
+ maximize_subscription: "Token Efficiency",
1233
+ };
1234
+ /**
1235
+ * The aligned "defaults Bridge chose" block printed once, right before the launch
1236
+ * consent question. Pure: it formats what the run already knows and performs no I/O.
1237
+ *
1238
+ * Layout: a header, then two rows of two cells. Both rows are indented two spaces and
1239
+ * the left cells are padded to the longer left cell plus three spaces, so the right
1240
+ * cells line up. The Ticket keys cell is omitted entirely when the server supplied no
1241
+ * valid prefix; the prefix is printed as given and never derived locally.
1242
+ */
1243
+ export function buildInstallBridgeDefaultsSummary(input) {
1244
+ const optimization = input.persistedPosture
1245
+ ? OPTIMIZATION_POSTURE_SUMMARY_LABELS[input.persistedPosture]
1246
+ : input.isNewProject
1247
+ ? "Quality"
1248
+ : "unchanged";
1249
+ const guidanceLabel = input.claudeCodeSelected ? "Claude Code guidance" : "Agent guidance";
1250
+ const guidanceValue = input.guidance.written.length > 0
1251
+ ? `added to ${input.guidance.written.join(", ")}`
1252
+ : input.guidance.unchanged.length > 0
1253
+ ? "already up to date"
1254
+ : "not written";
1255
+ const leftTop = `Project name: ${input.displayName}`;
1256
+ const leftBottom = `Optimization: ${optimization}`;
1257
+ const rightTop = input.ticketPrefix
1258
+ ? `Ticket keys: ${input.ticketPrefix}-1, ${input.ticketPrefix}-2, …`
1259
+ : undefined;
1260
+ const rightBottom = `${guidanceLabel}: ${guidanceValue}`;
1261
+ const width = Math.max(leftTop.length, leftBottom.length) + 3;
1262
+ const rowTop = rightTop === undefined ? ` ${leftTop}` : ` ${leftTop.padEnd(width)}${rightTop}`;
1263
+ const rowBottom = ` ${leftBottom.padEnd(width)}${rightBottom}`;
1264
+ return [
1265
+ "Bridge chose these defaults — change them later on the Setup page, or ask your agent:",
1266
+ rowTop,
1267
+ rowBottom,
1268
+ ].join("\n");
1269
+ }
1215
1270
  /**
1216
1271
  * Echoed single-line prompt on stderr (used for repo confirmation / value).
1217
1272
  * Exported so `index.ts`'s `--init` can share the exact same prompt mechanics
@@ -1328,6 +1383,7 @@ export function createDefaultInstallBridgeDeps() {
1328
1383
  return {
1329
1384
  env: process.env,
1330
1385
  cwd: process.cwd(),
1386
+ realpath: async (folder) => realpath(folder),
1331
1387
  platform: process.platform,
1332
1388
  homedir: os.homedir,
1333
1389
  isTTY,
@@ -1349,7 +1405,9 @@ export function createDefaultInstallBridgeDeps() {
1349
1405
  randomBytes: (size) => cryptoRandomBytes(size),
1350
1406
  promptSecret: isTTY ? promptSecretViaReadline : undefined,
1351
1407
  promptLine: isTTY ? promptLineViaReadline : undefined,
1352
- promptMultiSelect: isTTY ? promptMultiSelectViaReadline : undefined,
1408
+ promptMultiSelect: isTTY
1409
+ ? (promptText, options, preselected) => promptMultiSelectViaReadline(promptText, options, process.stdin, process.stderr, preselected)
1410
+ : undefined,
1353
1411
  vendor: createDefaultVendorProcessDeps(spawn),
1354
1412
  fetch: productionFetch,
1355
1413
  resolveRepoViaServer: (baseUrl, apiKey) => resolveRepoViaServer(productionFetch, baseUrl, apiKey),
@@ -1643,6 +1701,77 @@ function defaultShellFreeRunnerWithEnv(executable, args, extraEnv) {
1643
1701
  child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1644
1702
  });
1645
1703
  }
1704
+ /**
1705
+ * BAPI-1405 — the owner's own `gh`, shell-free, for the epic-branch CI step.
1706
+ *
1707
+ * Same inherited environment as {@link defaultShellFreeRunnerWithEnv} (the
1708
+ * customer's own login is the point), plus an optional stdin body so a ruleset
1709
+ * update can use `gh api --input -` and keep JSON out of argv. A spawn failure
1710
+ * (no `gh` on PATH) resolves as exit 127 rather than throwing.
1711
+ */
1712
+ export function defaultUserGhRunner(args, opts) {
1713
+ return new Promise((resolve) => {
1714
+ const child = spawn("gh", args, {
1715
+ shell: false,
1716
+ stdio: [opts?.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
1717
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" },
1718
+ });
1719
+ let stdout = "";
1720
+ let stderr = "";
1721
+ child.stdout?.on("data", (chunk) => {
1722
+ stdout += chunk.toString("utf-8");
1723
+ });
1724
+ child.stderr?.on("data", (chunk) => {
1725
+ stderr += chunk.toString("utf-8");
1726
+ });
1727
+ child.on("error", () => resolve({ exitCode: 127, stdout: "", stderr: "gh could not be started" }));
1728
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1729
+ if (opts?.input !== undefined) {
1730
+ child.stdin?.on("error", () => undefined);
1731
+ child.stdin?.end(opts.input);
1732
+ }
1733
+ });
1734
+ }
1735
+ /**
1736
+ * BAPI-1405 — the production epic-branch CI seams for one repository root.
1737
+ *
1738
+ * Provider detection reads `origin` with the owner's own git; only a canonical
1739
+ * github.com remote counts as GitHub (the same parser the merge-workflow
1740
+ * installer uses). The example epic branch is a local remote-tracking
1741
+ * `origin/epic/*` ref when one exists — a cheap, offline read — and otherwise
1742
+ * the gate module's never-created probe name.
1743
+ */
1744
+ export function buildEpicBranchCiDeps(cwd) {
1745
+ const git = async (args) => {
1746
+ try {
1747
+ const r = await defaultShellFreeRunner("git", ["-C", cwd, ...args]);
1748
+ return r.exitCode === 0 ? r.stdout.trim() : null;
1749
+ }
1750
+ catch {
1751
+ return null;
1752
+ }
1753
+ };
1754
+ return {
1755
+ fs: createNodeGateDiscoveryFs(cwd),
1756
+ runGh: defaultUserGhRunner,
1757
+ detectProvider: async () => {
1758
+ const remoteUrl = await git(["remote", "get-url", "origin"]);
1759
+ if (!remoteUrl)
1760
+ return { kind: "unknown" };
1761
+ const ownerRepo = parseGithubRemote(remoteUrl);
1762
+ return ownerRepo ? { kind: "github", ownerRepo } : { kind: "not_github" };
1763
+ },
1764
+ findExampleEpicBranch: async () => {
1765
+ const ref = await git([
1766
+ "for-each-ref",
1767
+ "--count=1",
1768
+ "--format=%(refname:strip=3)",
1769
+ "refs/remotes/origin/epic/",
1770
+ ]);
1771
+ return ref ? ref.split("\n")[0].trim() || null : null;
1772
+ },
1773
+ };
1774
+ }
1646
1775
  /** Presence probe: `<tool> --version` exiting 0 means the tool resolves on PATH. */
1647
1776
  async function defaultExecutablePresenceProbe(name) {
1648
1777
  try {
@@ -1954,9 +2083,17 @@ const INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE = /^[^@\s]+@[^@\s]+$/;
1954
2083
  /** The one hint line printed after a blank/malformed email, before the single re-prompt. */
1955
2084
  export const INSTALL_BRIDGE_SIGNUP_EMAIL_HINT = "Enter a valid email address (e.g. you@example.com).";
1956
2085
  export function buildInstallBridgeSignupPrivacyNotice(baseUrl) {
1957
- return `Privacy Policy: ${baseUrl.replace(/\/+$/, "")}/privacy`;
2086
+ return `Your email is used to verify signup and set up your Bridge workspace. Privacy Policy — how data is handled: ${baseUrl.replace(/\/+$/, "")}/privacy`;
2087
+ }
2088
+ export function buildInstallBridgeMaintenanceHint() {
2089
+ return [
2090
+ `Bridge MCP installer package: ${MCP_PACKAGE_NAME}@${VERSION}`,
2091
+ "From the project root, use /upgrade-bridge or run:",
2092
+ `npx -y ${MCP_PACKAGE_NAME}@latest --upgrade`,
2093
+ "Persistent host registrations remain pinned until an explicit upgrade; the editor may need to reconnect to use the new server.",
2094
+ ];
1958
2095
  }
1959
- export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_BASE_URL) {
2096
+ export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_BASE_URL, onboarding) {
1960
2097
  // BAPI-757 (R-3 / AC-9): self-serve signup now REQUIRES an interactive terminal,
1961
2098
  // and the check runs FIRST — before `--email` and before `BAPI_SIGNUP_EMAIL` —
1962
2099
  // so an unattended run fails before any request is made, no verification email
@@ -1967,6 +2104,13 @@ export async function resolveSignupEmail(options, deps, baseUrl = DEFAULT_BAPI_B
1967
2104
  if (!deps.isTTY || !deps.promptLine) {
1968
2105
  return { ok: false, error: INSTALL_BRIDGE_SIGNUP_REQUIRES_TTY };
1969
2106
  }
2107
+ // BAPI-1448: an email already answered at the merged entry prompt is consumed the
2108
+ // way `--email` is, with no second question and no second privacy notice.
2109
+ if (onboarding) {
2110
+ if (!onboarding.privacyNoticeShown)
2111
+ deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
2112
+ return { ok: true, value: onboarding.email };
2113
+ }
1970
2114
  deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
1971
2115
  if (typeof options.email === "string" && options.email.trim().length > 0) {
1972
2116
  return { ok: true, value: options.email.trim() };
@@ -2030,49 +2174,24 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
2030
2174
  return { kind: "have-key" };
2031
2175
  }
2032
2176
  /**
2033
- * The bare-TTY onboarding question (BAPI-708, Part A), replacing the BAPI-667/687
2034
- * three-option chooser.
2035
- *
2036
- * The three-option form asked the user to make a distinction the CODE can already
2037
- * make for itself: whether the string in their clipboard is an invite or an API
2038
- * key. `classifyEnteredCredential` + `isHighEntropyApiKeyShape` decide that from
2039
- * SHAPE alone, with no probe request — so the only thing a first-time user
2040
- * genuinely knows, and the only thing worth asking, is whether they have a token
2041
- * at all. Hence a yes/no question, with the route inferred afterwards.
2042
- *
2043
- * Interaction contract is unchanged from BAPI-667/687 and remains load-bearing:
2044
- * render the heading and the numbered options exactly once, accept ONE strict
2045
- * line, no default (a bare Enter selects nothing), bounded to a single re-prompt
2046
- * after one hint, and EOF resolves as an empty answer so it can never hang.
2047
- */
2048
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Welcome to Bridge - do you have a token yet?";
2049
- /**
2050
- * The question's numbered options, in render order. Index + 1 is the accepted
2051
- * token. BAPI-1171 swapped the order so the self-serve, need-a-token path is
2052
- * option 1 — first-time signup is the common case and now leads.
2053
- *
2054
- * "Token" is deliberately credential-agnostic: an invite and an API key are both
2055
- * tokens as far as this question is concerned, and answering "2" leads to ONE
2056
- * hidden prompt whose value selects the route by shape.
2177
+ * The bare-TTY entry prompt (BAPI-1448). One question replaces the BAPI-708 "do you
2178
+ * have a token yet? 1/2" chooser: a typed email selects self-serve signup, and a
2179
+ * blank answer selects the have-key branch, whose value is read ONLY through the
2180
+ * hidden `Paste your Bridge token (input hidden): ` prompt downstream.
2057
2181
  */
2058
- export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
2059
- "1. I need a token",
2060
- "2. I already have a token",
2061
- ];
2062
- /** The single-line answer prompt drawn after the options (no bracketed default). */
2063
- export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1 or 2: ";
2064
- /** The one hint line printed after a blank/invalid answer, before the single re-prompt. */
2065
- export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1 or 2.";
2182
+ export const INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT = "Email (or press Enter if you already have a token): ";
2183
+ /** The one hint line printed after a non-blank malformed answer, before the single re-prompt. */
2184
+ export const INSTALL_BRIDGE_EMAIL_OR_TOKEN_HINT = "Enter an email address, or press Enter if you already have a token.";
2066
2185
  /**
2067
2186
  * The one place the "no usable credential" copy lives (BAPI-667, U1-3).
2068
2187
  *
2069
- * Centralized because the chooser failure and {@link resolveApiKey}'s two failure
2188
+ * Centralized because the entry-prompt failure and {@link resolveApiKey}'s two failure
2070
2189
  * branches previously drifted: only one of them mentioned self-serve, and one
2071
2190
  * advised "try the hidden prompt again" — false, because the process has already
2072
2191
  * exited by the time the user reads it. All three now name the SAME three routes.
2073
2192
  */
2074
- 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 " +
2075
- "already have one (an invite or an API key — either one is accepted at the hidden prompt). " +
2193
+ 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 " +
2194
+ "already have a token (an invite or an API key — either one is accepted at the hidden prompt). " +
2076
2195
  "Non-interactively, pass --api-key <key> (or set BAPI_API_KEY) for an " +
2077
2196
  "existing project, --invite if you were sent an invite token, or --email <addr> to sign up.";
2078
2197
  /**
@@ -2120,7 +2239,7 @@ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install: on a bare int
2120
2239
  * key-on-invite exits non-zero) is preserved BY CONSTRUCTION. Do not "unify" the
2121
2240
  * two.
2122
2241
  */
2123
- export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
2242
+ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv, baseUrl = DEFAULT_BAPI_BASE_URL) {
2124
2243
  const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
2125
2244
  // An explicit need-key intent is already unambiguous — never re-ask it.
2126
2245
  if (branch.kind === "need-key")
@@ -2132,38 +2251,35 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
2132
2251
  }
2133
2252
  const promptLine = deps.promptLine;
2134
2253
  try {
2135
- // Render the heading + options EXACTLY once; a re-prompt after an invalid
2136
- // answer re-asks the question without redrawing the list (BAPI-663 contract).
2137
- deps.log(INSTALL_BRIDGE_KEY_SELECTOR_PROMPT);
2138
- for (const choice of INSTALL_BRIDGE_ONBOARDING_CHOICES)
2139
- deps.log(choice);
2254
+ // The answer may be an email, so the privacy notice comes first and prints once.
2255
+ deps.log(buildInstallBridgeSignupPrivacyNotice(baseUrl));
2140
2256
  // Bounded to two reads total: the first answer, then ONE re-prompt after a
2141
2257
  // single hint line. EOF resolves as an empty answer (promptLineViaReadline
2142
- // resolves "" on close), so it costs one re-prompt and then fails cleanly —
2143
- // it can never hang or silently select a branch.
2258
+ // resolves "" on close), which selects the have-key branch rather than hanging.
2144
2259
  for (let attempt = 0; attempt < 2; attempt += 1) {
2145
- const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
2146
- // BAPI-708 (A-1): the accepted tokens are exactly 1/2 (no letters, no
2147
- // aliases), preserving the one-strict-line contract. BAPI-1171 swapped the
2148
- // order so "1" is the self-serve need-key branch and "2" routes to the
2149
- // have-key branch's credential-agnostic prompt, whose value is classified by
2150
- // SHAPE — so a user holding an invite still lands in the redemption protocol
2151
- // without ever having been asked which kind of token they hold.
2152
- if (answer === "1") {
2260
+ const answer = String((await promptLine(INSTALL_BRIDGE_EMAIL_OR_TOKEN_PROMPT)) ?? "").trim();
2261
+ // Blank (or EOF): the user has a token; it is read only by the hidden prompt.
2262
+ if (answer.length === 0) {
2263
+ return { ok: true, branch: { kind: "have-key" }, credentialIntent: "inferred" };
2264
+ }
2265
+ // A typed value is accepted ONLY as a signup email. It is never classified as a
2266
+ // credential, logged, or persisted here; a token-shaped value fails the shape
2267
+ // check and gets the hint.
2268
+ if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(answer)) {
2153
2269
  return {
2154
2270
  ok: true,
2155
2271
  branch: { kind: "need-key", method: "self-serve" },
2156
2272
  credentialIntent: "inferred",
2273
+ onboardingEmail: answer,
2274
+ privacyNoticeShown: true,
2157
2275
  };
2158
2276
  }
2159
- if (answer === "2")
2160
- return { ok: true, branch: { kind: "have-key" }, credentialIntent: "inferred" };
2161
2277
  if (attempt === 0)
2162
- deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
2278
+ deps.log(INSTALL_BRIDGE_EMAIL_OR_TOKEN_HINT);
2163
2279
  }
2164
2280
  return {
2165
2281
  ok: false,
2166
- error: `No option was selected. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
2282
+ error: `No email or token choice was made. ${INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE}`,
2167
2283
  };
2168
2284
  }
2169
2285
  catch {
@@ -2272,22 +2388,31 @@ const SELECTION_TOKEN_PATTERN = /^[1-9][0-9]*$/;
2272
2388
  * Interactive single-shot numbered picker on stderr (TTY only). Renders the
2273
2389
  * heading and each option exactly once, then accepts ONE strict
2274
2390
  * comma-separated line of one-based numbers and resolves immediately — there
2275
- * is no preselected/toggle state and no separate confirmation step (BAPI-663).
2391
+ * is no toggle state and no separate confirmation step (BAPI-663).
2276
2392
  *
2277
- * A blank line or a line containing any invalid token (non-numeric, zero,
2278
- * signed, leading-zero, decimal, out-of-range) is rejected atomically — the
2279
- * whole line is discarded, one error line is printed, and the picker
2280
- * re-prompts without re-rendering the option list. A valid line is
2281
- * deduplicated and returned in `options` order regardless of entry order.
2393
+ * BAPI-1448: when `preselected` names at least one offered option, those rows
2394
+ * render as `[x]` (the rest as `[ ]`) and a blank line accepts the ticked set.
2395
+ * Typed numbers REPLACE the ticked set; they never toggle it. With nothing
2396
+ * pre-ticked the original behaviour is unchanged: a blank line is rejected with
2397
+ * "Select at least one tool."
2398
+ *
2399
+ * A line containing any invalid token (non-numeric, zero, signed,
2400
+ * leading-zero, decimal, out-of-range) is rejected atomically — the whole line
2401
+ * is discarded, one error line is printed, and the picker re-prompts without
2402
+ * re-rendering the option list, so the ticks stay as first shown. A valid line
2403
+ * is deduplicated and returned in `options` order regardless of entry order.
2282
2404
  *
2283
2405
  * Resolves to `[]` on EOF / synchronous close before any line has been
2284
2406
  * committed, so a readline regression can never hang. Returns `[]`
2285
2407
  * immediately, without creating a readline interface, when `options` is
2286
2408
  * empty.
2287
2409
  */
2288
- export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr) {
2410
+ export function promptMultiSelectViaReadline(promptText, options, input = process.stdin, output = process.stderr, preselected = []) {
2289
2411
  if (options.length === 0)
2290
2412
  return Promise.resolve([]);
2413
+ // Canonical option order, restricted to options actually offered.
2414
+ const ticked = options.filter((o) => preselected.includes(o.id)).map((o) => o.id);
2415
+ const hasTicks = ticked.length > 0;
2291
2416
  return new Promise((resolve) => {
2292
2417
  const rl = readline.createInterface({ input, output });
2293
2418
  let settled = false;
@@ -2304,13 +2429,20 @@ export function promptMultiSelectViaReadline(promptText, options, input = proces
2304
2429
  rl.on("close", () => finish([]));
2305
2430
  output.write(`\n${promptText}\n`);
2306
2431
  options.forEach((opt, idx) => {
2307
- output.write(` ${idx + 1}. ${opt.label}\n`);
2432
+ const box = hasTicks ? `${ticked.includes(opt.id) ? "[x]" : "[ ]"} ` : "";
2433
+ output.write(` ${idx + 1}. ${box}${opt.label}\n`);
2308
2434
  });
2309
- output.write("Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
2435
+ output.write(hasTicks
2436
+ ? "Press Enter to use the ticked tools, or type the numbers you use (e.g. 1,3).\n"
2437
+ : "Enter the number(s) of the tools you use (e.g. 1,3), then Enter.\n");
2310
2438
  const prompt = () => {
2311
2439
  rl.question("> ", (answer) => {
2312
2440
  const trimmed = answer.trim();
2313
2441
  if (trimmed.length === 0) {
2442
+ if (hasTicks) {
2443
+ finish(ticked);
2444
+ return;
2445
+ }
2314
2446
  output.write("Select at least one tool.\n");
2315
2447
  prompt();
2316
2448
  return;
@@ -2361,10 +2493,6 @@ export function optimizationPostureSubscriptionNounPhrase(primaryPlatform) {
2361
2493
  const mapped = primaryPlatform ? OPTIMIZATION_POSTURE_SUBSCRIPTION_NAMES[primaryPlatform] : undefined;
2362
2494
  return mapped ? `${mapped} subscription` : "coding subscription";
2363
2495
  }
2364
- /** Option three's exact wording, platform-aware (BAPI-1221 Step 2). */
2365
- function buildOptimizationPostureOptionThree(primaryPlatform) {
2366
- return `Get the most out of your ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)}?`;
2367
- }
2368
2496
  /** The neutral single-line correction reprompted for any malformed answer. */
2369
2497
  const OPTIMIZATION_POSTURE_CORRECTION = "Enter a single number from 1 to 3.";
2370
2498
  /**
@@ -2380,15 +2508,21 @@ const OPTIMIZATION_POSTURE_CORRECTION = "Enter a single number from 1 to 3.";
2380
2508
  const OPTIMIZATION_POSTURE_MAX_CORRECTIONS = 20;
2381
2509
  /**
2382
2510
  * Render the one-time posture menu and its input instruction. Exactly three
2383
- * vertically ordered numbered options; option three is platform-aware. Plain,
2384
- * wrap-safe text — no color-only state, icons, or decorative framing.
2511
+ * vertically ordered numbered options; option three names the user's own
2512
+ * subscription. Plain, wrap-safe text (every line within 80 columns) — no
2513
+ * color-only state, icons, or decorative framing.
2385
2514
  */
2386
2515
  function buildOptimizationPostureMenu(primaryPlatform) {
2387
- return ("How should Bridge optimize its work on this repository?\n" +
2388
- " 1. Quality\n" +
2389
- " 2. Speed\n" +
2390
- ` 3. ${buildOptimizationPostureOptionThree(primaryPlatform)}\n` +
2391
- `${OPTIMIZATION_POSTURE_CORRECTION} `);
2516
+ return ("How should Bridge balance quality and cost on this project?\n" +
2517
+ " 1. Quality — Bridge plans and reviews each ticket in full depth; slowest and\n" +
2518
+ " uses the most credits, but catches the most problems.\n" +
2519
+ " 2. Speed — Bridge plans in one pass and reviews once; faster and cheaper, but\n" +
2520
+ " may miss things on some tickets.\n" +
2521
+ " 3. Token Efficiency — Bridge will use Speed settings and hand simpler tickets\n" +
2522
+ " to smaller models more often, which stretches your\n" +
2523
+ ` ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)} the furthest.\n` +
2524
+ "Press Enter to keep Quality (or your saved choice).\n" +
2525
+ "Enter 1, 2, or 3: ");
2392
2526
  }
2393
2527
  /**
2394
2528
  * The purpose-built, strict single-select optimization-posture picker
@@ -2478,7 +2612,7 @@ function optimizationPostureConfirmationPhrase(value, primaryPlatform) {
2478
2612
  }
2479
2613
  /** Printed immediately after a successful posture write, on its own line. */
2480
2614
  const OPTIMIZATION_POSTURE_BLOG_URL = "https://bridgegpt.ai/blog/how-we-optimize-your-setup";
2481
- const OPTIMIZATION_POSTURE_REMEDIATION = "Update it later with /teach-bridge or the optimization_posture setting in the web UI.";
2615
+ const OPTIMIZATION_POSTURE_REMEDIATION = "Change it later by running /teach-bridge in your agent.";
2482
2616
  /**
2483
2617
  * The generic, secret-free fail-open message shared by every non-success,
2484
2618
  * non-forbidden outcome — an interactive no-answer/prompt-exception (no PUT
@@ -2496,7 +2630,7 @@ const OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE = ` Optimization posture was not c
2496
2630
  */
2497
2631
  function reportOptimizationPostureOutcome(log, result, value, primaryPlatform) {
2498
2632
  if (result.kind === "success") {
2499
- log(` Preference saved for later activation — we'll optimize for ${optimizationPostureConfirmationPhrase(value, primaryPlatform)}.`);
2633
+ log(` Preference saved — we'll optimize for ${optimizationPostureConfirmationPhrase(value, primaryPlatform)}.`);
2500
2634
  log(` ${OPTIMIZATION_POSTURE_BLOG_URL}`);
2501
2635
  return;
2502
2636
  }
@@ -2512,8 +2646,9 @@ function reportOptimizationPostureOutcome(log, result, value, primaryPlatform) {
2512
2646
  * Resolve the selected host platforms with strict precedence (BAPI-635):
2513
2647
  * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
2514
2648
  * back to detection),
2515
- * 2. interactive multi-select on a TTY (AC-2: no row pre-selected — the picker
2516
- * starts empty and requires an explicit ≥1 selection),
2649
+ * 2. interactive multi-select on a TTY, pre-ticked with the detected tools
2650
+ * (BAPI-1448 reverses the BAPI-635 AC-2 empty picker; with nothing detected
2651
+ * the picker still requires an explicit ≥1 selection),
2517
2652
  * 3. the legacy non-TTY automatic set: Claude Code plus only the currently
2518
2653
  * detected Copilot VS Code and Cursor automatic targets (never Codex just
2519
2654
  * because a global directory exists).
@@ -2523,11 +2658,12 @@ export async function resolveSelectedHostPlatforms(deps, options) {
2523
2658
  if (options.tools !== undefined) {
2524
2659
  return options.tools;
2525
2660
  }
2526
- // 2. Interactive single-shot numbered picker on a TTY. No row is pre-selected
2527
- // — the picker has no default state and requires an explicit selection.
2661
+ // 2. Interactive single-shot numbered picker on a TTY, seeded with the locally
2662
+ // detected tools (BAPI-1448): Enter accepts them, typed numbers replace them.
2528
2663
  if (deps.isTTY && deps.promptMultiSelect) {
2529
2664
  const optionList = allHostTargets().map((t) => ({ id: t.id, label: t.label }));
2530
- const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList);
2665
+ const detected = detectDefaultPlatforms(await buildDetectionContext(deps));
2666
+ const chosen = await deps.promptMultiSelect(INSTALL_BRIDGE_TOOL_SELECTOR_PROMPT, optionList, detected);
2531
2667
  return HOST_PLATFORM_ORDER.filter((id) => chosen.includes(id));
2532
2668
  }
2533
2669
  // 3. Legacy non-TTY automatic set: Claude + detected Cursor / Copilot VS Code.
@@ -2645,42 +2781,6 @@ export function toolLabelForLaunchAgent(agent) {
2645
2781
  const target = allHostTargets().find((t) => t.launchAgent === agent);
2646
2782
  return target?.label ?? UNKNOWN_LAUNCH_TOOL_LABEL;
2647
2783
  }
2648
- /**
2649
- * Interactive numbered chooser for when MORE THAN ONE launchable tool was selected
2650
- * (AC-11). Mirrors the connect-github picker: prints each candidate by its host-tool
2651
- * label, asks for an exact numeric choice with NO default, validates the range, and
2652
- * returns the chosen agent. Returns `null` — never a silent first-candidate default —
2653
- * for every unavailable/invalid path: non-TTY, a missing `promptLine` seam, blank/EOF
2654
- * input, an out-of-range or malformed answer, or a prompt that throws. Single-purpose:
2655
- * it must run only AFTER the multi-select promise has resolved and closed its readline
2656
- * interface so two readers never contend for stdin.
2657
- */
2658
- export async function chooseInstallBridgeLaunchAgent(agents, deps) {
2659
- if (!deps.isTTY || !deps.promptLine)
2660
- return null;
2661
- const promptLine = deps.promptLine;
2662
- try {
2663
- deps.log("");
2664
- deps.log("More than one selected tool can host the configuration session:");
2665
- agents.forEach((agent, i) => {
2666
- deps.log(` ${String(i + 1).padStart(2, " ")}. ${toolLabelForLaunchAgent(agent)}`);
2667
- });
2668
- // No default: a stray Enter must not silently pick a tool.
2669
- const answer = (await promptLine(`Which tool should open? [1-${agents.length}]: `)).trim();
2670
- const index = Number(answer);
2671
- if (!/^\d+$/.test(answer) ||
2672
- !Number.isInteger(index) ||
2673
- index < 1 ||
2674
- index > agents.length) {
2675
- return null;
2676
- }
2677
- return agents[index - 1];
2678
- }
2679
- catch {
2680
- // Never surface caught exception text; an unavailable chooser is a no-spawn.
2681
- return null;
2682
- }
2683
- }
2684
2784
  /**
2685
2785
  * Shared, copy-pasteable "we didn't open a session for you" guidance so every
2686
2786
  * no-spawn path (no-launchable selection, non-TTY suppression, chooser failure,
@@ -3386,6 +3486,7 @@ async function removeUnprovenBridgeConfig(deps, createdText) {
3386
3486
  * ONCE for the whole batch, so selecting both does not print it twice.
3387
3487
  */
3388
3488
  async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
3489
+ let complete = true;
3389
3490
  const logLines = [];
3390
3491
  const duplicateRegistrations = [];
3391
3492
  let anyManualRequired = false;
@@ -3418,14 +3519,17 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
3418
3519
  logLines.push(` configured ${target.label} (${outcome.displayPath})`);
3419
3520
  break;
3420
3521
  case "manual-required":
3522
+ complete = false;
3421
3523
  anyManualRequired = true;
3422
3524
  logLines.push(` ${target.label}: add the ${MCP_SERVER_NAME} MCP server manually to ${outcome.displayPath} ` +
3423
3525
  "(the API key is redacted in printed instructions).");
3424
3526
  break;
3425
3527
  case "skipped-invalid":
3528
+ complete = false;
3426
3529
  logLines.push(` ${target.label}: skipped ${outcome.displayPath} — existing config is not valid; left untouched.`);
3427
3530
  break;
3428
3531
  case "skipped-duplicate-registration":
3532
+ complete = false;
3429
3533
  // BAPI-807: NOT an ordinary vendor-fallback condition. The target was
3430
3534
  // left byte-identical and the caller must fail loudly rather than report
3431
3535
  // a configured host.
@@ -3433,6 +3537,7 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
3433
3537
  logLines.push(` ${target.label}: skipped ${outcome.displayPath} — ${outcome.detail}`);
3434
3538
  break;
3435
3539
  case "failed":
3540
+ complete = false;
3436
3541
  logLines.push(` ${target.label}: could not be configured automatically; configure it manually.`);
3437
3542
  break;
3438
3543
  }
@@ -3440,7 +3545,7 @@ async function provisionSelectedGlobalTargets(deps, platforms, entry, needKey) {
3440
3545
  if (needKey && anyManualRequired) {
3441
3546
  logLines.push(` ${formatNeedKeyCredentialStoreLine(needKey.repoName, needKey.credentialStorePath)}`);
3442
3547
  }
3443
- return { logLines, duplicateRegistrations };
3548
+ return { logLines, duplicateRegistrations, complete };
3444
3549
  }
3445
3550
  /** Build the `/jira/ping` URL exactly like the MCP `ping` tool / buildGetUrl. */
3446
3551
  export function buildPingUrl(baseUrl, repoName) {
@@ -3642,8 +3747,20 @@ export function generateBootstrapKeySecret(randomBytes) {
3642
3747
  * the identity guard in both shapes, and prepare / repoint / promote / discard all
3643
3748
  * still match on it.
3644
3749
  */
3645
- export function fingerprintBootstrapInvite(token) {
3646
- return createHash("sha256").update(token, "utf-8").digest("hex");
3750
+ export function fingerprintBootstrapInvite(token, baseUrl) {
3751
+ if (baseUrl === undefined)
3752
+ return createHash("sha256").update(token, "utf-8").digest("hex");
3753
+ return fingerprintBootstrapFields("bridge-bootstrap-invite-v1", [baseUrl, token]);
3754
+ }
3755
+ function fingerprintBootstrapFields(domain, fields) {
3756
+ const hash = createHash("sha256").update(domain + "\0", "utf-8");
3757
+ for (const field of fields) {
3758
+ const bytes = Buffer.from(field, "utf-8");
3759
+ const length = Buffer.alloc(4);
3760
+ length.writeUInt32BE(bytes.length);
3761
+ hash.update(length).update(bytes);
3762
+ }
3763
+ return hash.digest("hex");
3647
3764
  }
3648
3765
  /** The exchange endpoint: `<base>/setup/bootstrap`. No query string — ever. */
3649
3766
  export function buildBootstrapExchangeUrl(baseUrl) {
@@ -3659,15 +3776,15 @@ export function buildBootstrapExchangeUrl(baseUrl) {
3659
3776
  * the legacy three-field one, byte for byte.
3660
3777
  */
3661
3778
  export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret, email) {
3779
+ const modern = typeof repoName !== "string";
3780
+ const identity = modern ? { repo_display_name: validateBootstrapDisplayName(repoName.repoDisplayName) } : { repo_name: repoName };
3662
3781
  const url = buildBootstrapExchangeUrl(baseUrl);
3663
3782
  let resp;
3664
3783
  try {
3665
3784
  resp = await deps.fetch(url, {
3666
3785
  method: "POST",
3667
3786
  headers: { "Content-Type": "application/json" },
3668
- body: JSON.stringify(email
3669
- ? { token, repo_name: repoName, key_secret: keySecret, email }
3670
- : { token, repo_name: repoName, key_secret: keySecret }),
3787
+ body: JSON.stringify({ token, ...identity, key_secret: keySecret, ...(email ? { email } : {}) }),
3671
3788
  signal: AbortSignal.timeout(10_000),
3672
3789
  });
3673
3790
  }
@@ -3683,13 +3800,17 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
3683
3800
  ok: false,
3684
3801
  kind: "failed",
3685
3802
  message: `Could not reach the Bridge API at ${baseUrl} to redeem the bootstrap invite. Check ` +
3686
- "BAPI_BASE_URL and your network. The invite has not been used.",
3803
+ "BAPI_BASE_URL and your network. Creation outcome is unknown; preserve proof and rerun the same attempt.",
3687
3804
  };
3688
3805
  }
3689
3806
  if (resp.ok) {
3690
3807
  let repo;
3691
3808
  try {
3692
3809
  const body = (await resp.json());
3810
+ if (modern) {
3811
+ const identity = validateBootstrapServerIdentity(body);
3812
+ return { ok: true, repoName: identity.repo_name, displayName: identity.repo_display_name };
3813
+ }
3693
3814
  repo = body?.repo_name;
3694
3815
  }
3695
3816
  catch {
@@ -3711,7 +3832,7 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
3711
3832
  }
3712
3833
  return { ok: true, repoName: validated.value };
3713
3834
  }
3714
- if (resp.status === 409) {
3835
+ if (resp.status === 409 && !modern) {
3715
3836
  return {
3716
3837
  ok: false,
3717
3838
  kind: "repo-name-taken",
@@ -3744,7 +3865,7 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
3744
3865
  return {
3745
3866
  ok: false,
3746
3867
  kind: "failed",
3747
- message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
3868
+ 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.`,
3748
3869
  };
3749
3870
  }
3750
3871
  /** The invite-email prompt (BAPI-1263), shown once after an `email_required` refusal. */
@@ -3910,6 +4031,27 @@ export function buildSelfServeMintUrl(baseUrl) {
3910
4031
  export const SIGNUP_VERIFICATION_CODE_LENGTH = 6;
3911
4032
  /** The one prompt label. The E2E harness and the static contract both pin it. */
3912
4033
  export const INSTALL_BRIDGE_CODE_PROMPT = "Email verification code: ";
4034
+ /**
4035
+ * The address verification codes are sent from. Mirrors the server's
4036
+ * `DEFAULT_FROM_ADDRESS` in `api/library/email/provider.py`; keep the two in step.
4037
+ */
4038
+ export const INSTALL_BRIDGE_VERIFICATION_SENDER = "noreply@bridgegpt-api.com";
4039
+ /**
4040
+ * The shared lead-in printed immediately before every self-serve
4041
+ * {@link promptForVerificationCode} call (modern and legacy paths, fresh or resumed):
4042
+ * three unindented lines, then a blank line. It prints neither the prompt nor any
4043
+ * expiry or time-left text.
4044
+ *
4045
+ * BAPI-1449 (verification recovery) builds on this lead-in instead of adding its
4046
+ * own, and may later return the sender from the request endpoint rather than using
4047
+ * {@link INSTALL_BRIDGE_VERIFICATION_SENDER}.
4048
+ */
4049
+ export function logVerificationCodeLeadIn(log, email) {
4050
+ log(`We emailed a ${SIGNUP_VERIFICATION_CODE_LENGTH}-digit code to ${maskSignupEmail(email)}.`);
4051
+ log(`It usually arrives within two minutes. It comes from ${INSTALL_BRIDGE_VERIFICATION_SENDER} — check your`);
4052
+ log("spam or junk folder if you don't see it.");
4053
+ log("");
4054
+ }
3913
4055
  /** Shown when a stored, unexpired challenge is picked up on a later run. */
3914
4056
  export const INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING = "Resume email verification";
3915
4057
  /**
@@ -4680,8 +4822,8 @@ function buildDeferredLaunchStepPreview() {
4680
4822
  /**
4681
4823
  * The optional agent-notes offer preview line (BAPI-1207). A --dry-run never
4682
4824
  * prompts or writes; when the selection resolves to at least one writable
4683
- * notes target this states plainly that a real interactive run would ask
4684
- * default-No consent before touching it. Empty when the selection resolves to
4825
+ * notes target this states plainly that a real interactive run writes it
4826
+ * without asking. Empty when the selection resolves to
4685
4827
  * no writable target (e.g. Windsurf-only), mirroring how the GitHub offer
4686
4828
  * lines are unconditional but this offer's applicability is not.
4687
4829
  */
@@ -4696,10 +4838,10 @@ function buildAgentNotesPreviewLines(notesTargets, platformLabels) {
4696
4838
  "Step 4c — reached only after the Step 4b VCS connect offer has completed or been",
4697
4839
  " explicitly deferred (a declined or incomplete connect is announced first,",
4698
4840
  " with the command to finish it later, and never blocks this step).",
4699
- `Step 4c — optional: Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4700
- ` terminal is interactive, offer to write guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4701
- ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}; defaults`,
4702
- " to No, and existing content outside that region is always preserved. No notes files are",
4841
+ `Step 4c — Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4842
+ ` terminal is interactive, a real run writes guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4843
+ ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}, without asking,`,
4844
+ " then prints how to remove it. Existing content outside that region is preserved. No notes files are",
4703
4845
  " written in --dry-run.",
4704
4846
  ];
4705
4847
  }
@@ -5315,6 +5457,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5315
5457
  // Built from the FINAL merged deps for the same reason (BAPI-775): a test
5316
5458
  // that overrides only `readFile` or only `cwd` must see its override here.
5317
5459
  localProvisioning: buildConductorLocalDeps(deps),
5460
+ // BAPI-1391: the read-only discovery filesystem over THIS working tree, and
5461
+ // the short-lived qualifying executor (the existing executor CLI with a
5462
+ // `runExecutor` override limiting it to `gate_qualification` jobs).
5463
+ gate: deps.conductorGate ?? {
5464
+ discoveryFs: createNodeGateDiscoveryFs(deps.cwd),
5465
+ // BAPI-1405: epic-branch CI inspection over this working tree, with the
5466
+ // owner's own shell-free `gh`.
5467
+ epicBranchCi: buildEpicBranchCiDeps(deps.cwd),
5468
+ runQualificationExecutor: createQualificationExecutorRunner({
5469
+ runExecutorCli,
5470
+ runExecutor,
5471
+ errorLog: deps.errorLog,
5472
+ }),
5473
+ },
5318
5474
  };
5319
5475
  return runInstallBridgeConductorCli(argv.slice(1), conductorDeps);
5320
5476
  }
@@ -5330,6 +5486,29 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5330
5486
  return 1;
5331
5487
  }
5332
5488
  const options = parsed.options;
5489
+ if (options.displayName !== undefined && (options.repo !== undefined || deps.env.BAPI_REPO_NAME?.trim())) {
5490
+ errorLog("Error: --display-name conflicts with --repo or BAPI_REPO_NAME. Use a friendly label for creation or a canonical selector for legacy creation/reconnect.");
5491
+ return 1;
5492
+ }
5493
+ if (options.displayName !== undefined && !options.inviteMode && !options.email && !deps.env.BAPI_INVITE?.trim() && !deps.env.BAPI_SIGNUP_EMAIL?.trim() && (options.apiKey !== undefined || deps.env.BAPI_API_KEY?.trim())) {
5494
+ errorLog("Error: --display-name applies only to new creation. Existing keys use canonical --repo selection or single-project resolution.");
5495
+ return 1;
5496
+ }
5497
+ // ---- BAPI-1398: --remove-bridge-permissions — removal only, nothing else ----
5498
+ // Ahead of the base-URL check, onboarding, every prompt, every credential read,
5499
+ // and every network call: it touches only the two settings files and Bridge's
5500
+ // ownership manifest.
5501
+ if (options.removeBridgePermissions) {
5502
+ return runBridgePermissionsRemoval({
5503
+ cwd: deps.cwd,
5504
+ homeDir: deps.homedir(),
5505
+ readFile: deps.readFile,
5506
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
5507
+ mkdir: (p, o) => deps.mkdir(p, o),
5508
+ log,
5509
+ errorLog,
5510
+ });
5511
+ }
5333
5512
  // Deliberately still ahead of the onboarding branch, any prompt, any network
5334
5513
  // call, and the --dry-run preview: a scheme-less value (the classic error) used
5335
5514
  // to throw ERR_INVALID_URL deep inside `buildPingUrl` — even in --dry-run — often
@@ -5490,7 +5669,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5490
5669
  // The selection is pure and deterministic for every explicit, env-driven, and
5491
5670
  // non-TTY invocation; only a BARE interactive run is asked which branch it wants
5492
5671
  // (BAPI-626 — otherwise a first-time user can never reach self-serve).
5493
- const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv);
5672
+ const branchResult = await resolveInstallBridgeOnboardingBranchForRun(options, deps, argv, baseUrl);
5494
5673
  if (!branchResult.ok) {
5495
5674
  errorLog(`Error: ${branchResult.error}`);
5496
5675
  return 1;
@@ -5532,7 +5711,12 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5532
5711
  // key-authentication ping, so there is no source to attribute.
5533
5712
  let apiKeySource;
5534
5713
  if (selfServeSignupMode) {
5535
- const emailResult = await resolveSignupEmail(options, deps, baseUrl);
5714
+ const emailResult = await resolveSignupEmail(options, deps, baseUrl, branchResult.onboardingEmail
5715
+ ? {
5716
+ email: branchResult.onboardingEmail,
5717
+ privacyNoticeShown: branchResult.privacyNoticeShown === true,
5718
+ }
5719
+ : undefined);
5536
5720
  if (!emailResult.ok) {
5537
5721
  errorLog(`Error: ${emailResult.error}`);
5538
5722
  return 1;
@@ -5667,6 +5851,56 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5667
5851
  // 404 is the BAPI-616 feature-detect for an older or self-hosted server, and
5668
5852
  // `error` covers a transient network failure or a 401. Collapsing these into
5669
5853
  // the hard stop would make a blip unrecoverable.
5854
+ if (!bootstrapInviteMode && options.displayName !== undefined) {
5855
+ errorLog("Error: --display-name applies only to creation; existing-key registration uses canonical selection.");
5856
+ return 1;
5857
+ }
5858
+ let modernAttempt;
5859
+ if (options.tools?.length === 0 && !options.agentName) {
5860
+ log(buildManualInstallBridgeContinuation("empty-selection", []));
5861
+ return 0;
5862
+ }
5863
+ if (bootstrapInviteMode && resolveConfiguredRepoName(options, deps.env) === undefined) {
5864
+ if (!selfServeSignupMode) {
5865
+ const legacy = await lookupLegacyBootstrapInvite(fingerprintBootstrapInvite(inviteToken), modernWriteDeps(deps));
5866
+ if (!legacy.ok) {
5867
+ errorLog(legacy.error);
5868
+ return 1;
5869
+ }
5870
+ if (legacy.repoName)
5871
+ options.repo = legacy.repoName;
5872
+ }
5873
+ else {
5874
+ // Historical default creation used the folder basename as its canonical name.
5875
+ const oldName = validateRepoName(path.basename(deps.cwd));
5876
+ if (oldName.ok) {
5877
+ const legacy = await deps.lookupSelfServeBootstrapPending({ repoName: oldName.value, now: deps.now() }, modernWriteDeps(deps));
5878
+ if (!legacy.ok) {
5879
+ errorLog(legacy.error);
5880
+ return 1;
5881
+ }
5882
+ if (legacy.state === "resumable" || legacy.state === "challenge")
5883
+ options.repo = oldName.value;
5884
+ if (legacy.state === "challenge-expired") {
5885
+ errorLog("Saved signup expired. Preserve proof and contact installation support.");
5886
+ return 1;
5887
+ }
5888
+ }
5889
+ }
5890
+ if (options.repo === undefined) {
5891
+ try {
5892
+ modernAttempt = await redeemModernInstallAttempt(deps, options, baseUrl, inviteToken, signupEmail, selfServeSignupMode);
5893
+ apiKey = modernAttempt.record.keySecret;
5894
+ options.repo = modernAttempt.repoName;
5895
+ bootstrapInviteMode = false;
5896
+ deps.log(`Project '${modernAttempt.displayName}' recovered; completing installation.`);
5897
+ }
5898
+ catch (error) {
5899
+ 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.");
5900
+ return 1;
5901
+ }
5902
+ }
5903
+ }
5670
5904
  let repoName;
5671
5905
  /** The resolver's OUTCOME (C-4), or undefined when it was never called. */
5672
5906
  let serverRepoResolutionStatus;
@@ -5798,6 +6032,24 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5798
6032
  // before any of the credential/repo/host resolution above — so by this point
5799
6033
  // the run is unconditionally the LIVE path. See the early return above for
5800
6034
  // the inert preview.
6035
+ // One advisory per live interactive handoff. Never construct probe dependencies
6036
+ // on dry-run/noninteractive paths, and never change install/consent outcomes.
6037
+ let advisoryShown = false;
6038
+ const showFirstBuildAdvisory = async (agent) => {
6039
+ if (advisoryShown || !deps.isTTY || options.dryRun)
6040
+ return;
6041
+ advisoryShown = true;
6042
+ try {
6043
+ const advisory = await collectFirstBuildAdvisory({
6044
+ decision: agent ? { kind: "spawn", agent } : { kind: "manual", reason: "manual-continuation" },
6045
+ configuredHosts: labelsForPlatforms(selectedPlatforms),
6046
+ }, { ...deps.startTicketsDeps, platform: deps.platform, env: deps.env, cwd: deps.cwd });
6047
+ log(formatFirstBuildAdvisory(advisory));
6048
+ }
6049
+ catch {
6050
+ log(`First-build prerequisites: diagnostic availability unknown. Run ${INSTALL_BRIDGE_DOCTOR_COMMAND} for read-only follow-up.`);
6051
+ }
6052
+ };
5801
6053
  // ---- Resolved-empty selection: a handled no-op (exit 0), never a Claude fallback ----
5802
6054
  // AC-3: with zero configured tools there is nothing to write and no session to open.
5803
6055
  // Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a
@@ -5805,14 +6057,15 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5805
6057
  if (planLaunch.kind === "manual" && planLaunch.reason === "empty-selection") {
5806
6058
  // Explicit empty selection: no labels to name, so the generic fallback is
5807
6059
  // intentional here — pass [] rather than `labelsForPlatforms(selectedPlatforms)`.
6060
+ await showFirstBuildAdvisory(null);
5808
6061
  log(buildManualInstallBridgeContinuation("empty-selection", []));
5809
6062
  return 0;
5810
6063
  }
5811
6064
  // ---- Finalize the launch agent for spawn-capable decisions ----
5812
- // choose-one: pick exactly one via the numbered chooser (TTY only); a non-TTY run or
5813
- // a failed/aborted chooser degrades to the manual continuation (finalAgentName stays
5814
- // null). This runs AFTER the multi-select readline closed and BEFORE the GitHub /
5815
- // consent flows, so no two stdin readers ever overlap.
6065
+ // choose-one: on a TTY, take the first launchable agent the user selected and say so
6066
+ // (BAPI-1448 retired the launch-agent chooser; `--agent` still wins
6067
+ // upstream in the pure launch decision). A non-TTY run degrades to the manual
6068
+ // continuation (finalAgentName stays null).
5816
6069
  let finalAgentName = null;
5817
6070
  let finalSpawnCommand = null;
5818
6071
  if (planLaunch.kind === "spawn") {
@@ -5820,8 +6073,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5820
6073
  finalSpawnCommand = planLaunch.spawnCommand;
5821
6074
  }
5822
6075
  else if (planLaunch.kind === "choose-one") {
5823
- const chosen = await chooseInstallBridgeLaunchAgent(planLaunch.agents, deps);
6076
+ const chosen = deps.isTTY ? (planLaunch.agents[0] ?? null) : null;
5824
6077
  if (chosen) {
6078
+ log(`Using ${toolLabelForLaunchAgent(chosen)} for the setup session (the first tool you selected). Pass --agent to choose another.`);
5825
6079
  const spec = resolveAgentSpec(chosen);
5826
6080
  if (!spec) {
5827
6081
  errorLog(`Error: no launch agent is registered for '${chosen}'.`);
@@ -5831,7 +6085,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5831
6085
  const modelAlias = await resolveValidatedPremiumModelAlias(deps, spec);
5832
6086
  finalSpawnCommand = deps.buildShellCommand(spec, INSTALL_BRIDGE_AGENT_PROMPT, deps.cwd, deps.platform, modelAlias);
5833
6087
  }
5834
- // chosen === null → non-TTY / unavailable / invalid → manual continuation below.
6088
+ // chosen === null → non-TTY → manual continuation below.
5835
6089
  }
5836
6090
  // planLaunch.kind === "manual" (no-launchable) → finalAgentName stays null.
5837
6091
  // ---- Materialize the Step 5 launch command + kick off the pre-warm — ONLY for a
@@ -6020,7 +6274,6 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6020
6274
  challengeExpiresAt = liveChallenge.expiresAt;
6021
6275
  challengeEmail = liveChallenge.email;
6022
6276
  log(` ${INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING}`);
6023
- log(` We already emailed a code to ${maskSignupEmail(challengeEmail)}.`);
6024
6277
  }
6025
6278
  else {
6026
6279
  const requested = await requestSelfServeChallenge(deps, baseUrl, signupEmail);
@@ -6080,12 +6333,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6080
6333
  "No workspace has been set up — fix the problem and re-run.");
6081
6334
  return false;
6082
6335
  }
6083
- log(` We emailed a code to ${maskSignupEmail(challengeEmail)}.`);
6084
6336
  }
6085
- log(` Enter the ${SIGNUP_VERIFICATION_CODE_LENGTH}-digit code to finish setting up your workspace.`);
6086
- const remaining = formatChallengeRemaining(challengeExpiresAt, deps.now());
6087
- if (remaining)
6088
- log(` ${remaining}`);
6337
+ // BAPI-1448: the shared lead-in, fresh or resumed, directly before code entry.
6338
+ logVerificationCodeLeadIn(log, challengeEmail);
6089
6339
  // Phase 2, second half, and phase 3. One resend is offered on the recovery
6090
6340
  // path only — never proactively — so the budget is spent on the user's
6091
6341
  // explicit request rather than by the CLI's own retry logic.
@@ -6467,7 +6717,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6467
6717
  log(currentStep);
6468
6718
  }
6469
6719
  // BAPI-668: a bootstrap-minted key is verified WITHOUT source attribution.
6470
- const ping = await verifyConnectivity(deps, baseUrl, repoName, apiKey, bootstrapInviteMode ? undefined : apiKeySource);
6720
+ const ping = modernAttempt ? { ok: true } : await verifyConnectivity(deps, baseUrl, repoName, apiKey, bootstrapInviteMode ? undefined : apiKeySource);
6471
6721
  if (!ping.ok) {
6472
6722
  // BAPI-667: in either need-key mode the exchange already succeeded and the
6473
6723
  // pending record survives, so this failure IS resumable — but only with the
@@ -6482,14 +6732,22 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6482
6732
  // file. Advisory: it warns on any problem and never fails the install.
6483
6733
  unprovenBridgeConfigPending = false;
6484
6734
  await reconcileBridgeConfigAfterPing(deps, repoName, scaffoldedBridgeConfigText);
6735
+ if (modernAttempt) {
6736
+ const reconciled = parseBridgeConfigToml(await deps.readFile(bridgeConfigPath(deps.cwd)));
6737
+ if (!reconciled.ok || reconciled.manifest.repoName !== repoName)
6738
+ return fatal("Installation incomplete: canonical configuration read-back failed. Preserve proof and resume from this folder.");
6739
+ }
6485
6740
  // ---- BAPI-1221 (S2): persist the captured posture, now that connectivity
6486
6741
  // is validated and `repoName`/`apiKey` are both final. Overwrites any
6487
6742
  // existing value unconditionally — the old value is never read first. A
6488
6743
  // no-answer (or a prompt/stream exception the picker already absorbed)
6489
6744
  // reports the same fail-open remediation without ever attempting a write.
6745
+ let persistedPosture;
6490
6746
  if (optimizationPostureSelection) {
6491
6747
  const postureResult = await persistOptimizationPosture(deps, baseUrl, apiKey, repoName, optimizationPostureSelection);
6492
6748
  reportOptimizationPostureOutcome(log, postureResult, optimizationPostureSelection, selectedPlatforms[0]);
6749
+ if (postureResult.kind === "success")
6750
+ persistedPosture = optimizationPostureSelection;
6493
6751
  }
6494
6752
  else if (optimizationPostureEligible) {
6495
6753
  log(OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE);
@@ -6535,6 +6793,21 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6535
6793
  trackedState.set(target.relPath, await isTrackedProjectConfig(deps, target.relPath));
6536
6794
  }
6537
6795
  const writeResult = await writeHostConfigs(deps, targets, { real: entry, secretFree: secretFreeEntry }, trackedState, { repoName, credentialStorePath });
6796
+ if (modernAttempt) {
6797
+ if (writeResult.skipped.length || writeResult.written.length !== targets.length) {
6798
+ return fatal("Installation incomplete: selected host configuration was declined or failed. Recovery proof is preserved.");
6799
+ }
6800
+ for (const item of writeResult.written) {
6801
+ const config = JSON.parse(await deps.readFile(path.join(deps.cwd, item.relPath)));
6802
+ const target = targets.find(target => target.relPath === item.relPath);
6803
+ const binding = target ? config[target.topLevelKey]?.[item.registrationKey] : undefined;
6804
+ const keyMatches = item.mode === "secret-free"
6805
+ ? binding?.env && !Object.prototype.hasOwnProperty.call(binding.env, "BAPI_API_KEY")
6806
+ : binding?.env?.BAPI_API_KEY === apiKey;
6807
+ if (binding?.env?.BAPI_REPO_NAME !== repoName || !keyMatches)
6808
+ return fatal("Installation incomplete: host binding read-back failed. Recovery proof is preserved.");
6809
+ }
6810
+ }
6538
6811
  // BAPI-807: a both-key project config is a FAIL-LOUD condition, not an
6539
6812
  // ordinary skip. Nothing was written (the pre-write scan returned before the
6540
6813
  // first target), and continuing would report a successful install over a
@@ -6572,6 +6845,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6572
6845
  const globalProvisioning = await provisionSelectedGlobalTargets(deps, selectedPlatforms, entry, needKeyManual);
6573
6846
  for (const line of globalProvisioning.logLines)
6574
6847
  log(line);
6848
+ if (modernAttempt && !globalProvisioning.complete)
6849
+ return fatal("Installation incomplete: a selected host requires completion. Recovery proof is preserved.");
6575
6850
  // BAPI-807: a global target that refused because it already carries both
6576
6851
  // registrations is fail-loud, exactly like the project-config case above —
6577
6852
  // never an ordinary vendor fallback the run can shrug off.
@@ -6653,7 +6928,21 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6653
6928
  // in bootstrap mode the exchange loop above is the ONLY way to reach here, so the
6654
6929
  // two are equivalent — but keying promotion to the same state that justifies the
6655
6930
  // catch block's resume guidance makes that invariant explicit and un-driftable.
6656
- if (bootstrapExchangeSucceeded) {
6931
+ if (modernAttempt) {
6932
+ currentStep = INSTALL_BRIDGE_STEP_LABELS.persistCredential;
6933
+ log(currentStep);
6934
+ let promoted = await promoteModernBootstrapCredential(modernAttempt.scope, modernAttempt.record, credentialWriteDeps, options.force);
6935
+ if (!promoted.ok && promoted.needsConsent && deps.isTTY && deps.promptLine) {
6936
+ const consent = (await deps.promptLine("Replace the existing canonical project credential? [y/N]: ")).trim().toLowerCase();
6937
+ if (consent === "y" || consent === "yes")
6938
+ promoted = await promoteModernBootstrapCredential(modernAttempt.scope, modernAttempt.record, credentialWriteDeps, true, promoted.observedKey);
6939
+ }
6940
+ if (!promoted.ok)
6941
+ return fatal("Installation incomplete: credential promotion failed. Preserve proof and resume from the same folder.");
6942
+ log(formatProjectCredentialStoreLine(repoName, credentialStorePath));
6943
+ log(`Project '${modernAttempt.displayName}' installed successfully.`);
6944
+ }
6945
+ else if (bootstrapExchangeSucceeded) {
6657
6946
  // FAIL-CLOSED (unlike normal mode below). The pending secret IS the minted admin
6658
6947
  // key — if it never lands under bapi:<repo>, the user has a live key they cannot
6659
6948
  // resolve. Promotion removes the pending record and writes the credential in one
@@ -6742,8 +7031,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6742
7031
  // selected set. `writeAgentNotes` never touches `writeMcpInstallState` or any
6743
7032
  // other persisted install-state contract — consent is re-evaluated every run.
6744
7033
  const notesPlan = resolveAgentNotesTargets(selectedPlatforms);
6745
- const notesPlatformLabels = labelsForPlatforms(notesPlan.supportedPlatforms);
6746
- const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets, notesPlatformLabels);
7034
+ const notesOffer = offerAgentGuidanceNotes(deps, notesPlan.targets);
6747
7035
  let notesResult;
6748
7036
  let notesWriteFailed = false;
6749
7037
  if (notesOffer.kind === "consented") {
@@ -6759,6 +7047,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6759
7047
  for (const file of notesResult.files) {
6760
7048
  if (file.status === "failed") {
6761
7049
  errorLog(` warning: could not update ${file.target} with agent notes (${file.reason}).`);
7050
+ errorLog(` Resolve the reported issue in ${file.target}, then rerun an interactive install to retry guidance.`);
6762
7051
  }
6763
7052
  }
6764
7053
  }
@@ -6772,6 +7061,42 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6772
7061
  const notesSummaryLine = buildAgentNotesSummaryLine(notesOffer, notesResult, notesWriteFailed, notesPlan.unsupportedPlatforms);
6773
7062
  if (notesSummaryLine)
6774
7063
  log(notesSummaryLine);
7064
+ // The removal hint only when something was actually written (BAPI-1448).
7065
+ const notesWrittenTargets = (notesResult?.files ?? [])
7066
+ .filter((f) => f.status === "written")
7067
+ .map((f) => f.target);
7068
+ if (notesWrittenTargets.length > 0)
7069
+ log(INSTALL_BRIDGE_AGENT_NOTES_REMOVAL_HINT);
7070
+ const notesUnchangedTargets = (notesResult?.files ?? [])
7071
+ .filter((f) => f.status === "skipped")
7072
+ .map((f) => f.target);
7073
+ // ---- BAPI-1398: Bridge permission rules for /drive-epic-run (Claude Code) ----
7074
+ // After the durable install work and the other offers, before the session
7075
+ // opens. Project rules are written as part of the normal install. The user
7076
+ // settings change is NOT part of install (BAPI-1448): it lives in the
7077
+ // `bridge-permissions` subcommand, offered in-session by /drive-epic-run. Every
7078
+ // failure here is advisory: a malformed settings file is reported and skipped,
7079
+ // and the install still completes.
7080
+ if (selectedPlatforms.includes("claude-code")) {
7081
+ try {
7082
+ await (deps.installBridgePermissions ?? runBridgePermissionsInstallStep)({
7083
+ cwd: deps.cwd,
7084
+ homeDir: deps.homedir(),
7085
+ env: deps.env,
7086
+ readFile: deps.readFile,
7087
+ writeFile: (p, data) => deps.writeFile(p, data, { encoding: "utf-8" }),
7088
+ mkdir: (p, o) => deps.mkdir(p, o),
7089
+ log,
7090
+ errorLog,
7091
+ ensureGitignored: (relPath) => ensureGitignoredShared(deps.cwd, relPath, gitignoreDeps),
7092
+ // BAPI-1446: pin the SessionStart upgrade-notice hook to the effective Claude Code pin.
7093
+ sessionStartHook: { pinnedVersion: await discoverClaudeCodeEffectivePin({ cwd: deps.cwd, readFile: deps.readFile, targetVersion: VERSION }) },
7094
+ });
7095
+ }
7096
+ catch {
7097
+ errorLog(" warning: could not write Bridge's permission rules (unexpected error; non-fatal).");
7098
+ }
7099
+ }
6775
7100
  // ---- Step 3b (await) — settle the background pre-warm before the session opens ----
6776
7101
  // The warm was kicked off (only on a spawn-capable path) right after the
6777
7102
  // launch-command guards; await it HERE so the bucket is settled before the spawned
@@ -6794,6 +7119,30 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6794
7119
  `The first MCP launch may pay a one-time cold install and could be slow. ${MCP_TIMEOUT_GUIDANCE}`);
6795
7120
  }
6796
7121
  }
7122
+ for (const line of buildInstallBridgeMaintenanceHint())
7123
+ log(line);
7124
+ await showFirstBuildAdvisory(finalAgentName);
7125
+ // ---- BAPI-1448: the defaults summary, once, right before the launch consent ----
7126
+ // Every input is already in memory except the optional ticket prefix, which is one
7127
+ // bounded read of the manifest the GitHub offer already uses. Any failure there just
7128
+ // omits the cell: the summary never blocks or changes the install's outcome.
7129
+ let ticketPrefix;
7130
+ try {
7131
+ const details = await fetchGithubConfigurationDetails({ fetch: deps.fetch, baseUrl, apiKey }, repoName);
7132
+ ticketPrefix = details.ticketPrefix;
7133
+ }
7134
+ catch {
7135
+ ticketPrefix = undefined;
7136
+ }
7137
+ log("");
7138
+ log(buildInstallBridgeDefaultsSummary({
7139
+ displayName: modernAttempt?.displayName ?? repoName,
7140
+ ...(ticketPrefix ? { ticketPrefix } : {}),
7141
+ isNewProject: Boolean(modernAttempt) || bootstrapExchangeSucceeded,
7142
+ ...(persistedPosture ? { persistedPosture } : {}),
7143
+ claudeCodeSelected: selectedPlatforms.includes("claude-code"),
7144
+ guidance: { written: notesWrittenTargets, unchanged: notesUnchangedTargets },
7145
+ }));
6797
7146
  // ---- Step 5 — consent-gate, then spawn the selected tool (or print continuation) ----
6798
7147
  // AC-7: for a spawn-capable path, ask Y/N first (`requestInstallBridgeLaunchConsent`
6799
7148
  // resolves to no-spawn without prompting on a non-TTY run). A decline, a no-launchable
@@ -6908,3 +7257,124 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6908
7257
  }
6909
7258
  }
6910
7259
  }
7260
+ class ModernInstallRecoveryError extends Error {
7261
+ }
7262
+ function modernWriteDeps(deps) {
7263
+ return { env: deps.env, homedir: deps.homedir, platform: deps.platform, readFile: deps.readFile,
7264
+ mkdir: deps.mkdir, writeFile: deps.writeFile, rename: deps.rename, chmod: deps.chmod, unlink: deps.unlink, open: deps.open };
7265
+ }
7266
+ async function redeemModernInstallAttempt(deps, options, baseUrl, token, signupEmail, selfServe) {
7267
+ if (!deps.realpath)
7268
+ throw new ModernInstallRecoveryError("Canonical folder resolution is unavailable. Contact installation support.");
7269
+ const folder = await deps.realpath(deps.cwd);
7270
+ const folderDigest = fingerprintBootstrapFields("bridge-bootstrap-folder-v1", [baseUrl, folder]);
7271
+ const signupLocator = `bootstrap-pending:signup:${folderDigest}`;
7272
+ const makeScope = (locator) => ({ baseUrl, folder, locator, folderReference: `bootstrap-folder:${folderDigest}` });
7273
+ let fingerprint = token ? fingerprintBootstrapInvite(token, baseUrl) : "";
7274
+ let scope = makeScope(selfServe ? signupLocator : `bootstrap-pending:invite:${fingerprint}`);
7275
+ const writeDeps = modernWriteDeps(deps);
7276
+ const loaded = await lookupModernBootstrapRecord(scope, writeDeps);
7277
+ if (!loaded.ok)
7278
+ throw new Error(loaded.error);
7279
+ let record = loaded.record;
7280
+ const resumed = record !== null;
7281
+ let label = record?.label ?? options.displayName;
7282
+ if (label === undefined) {
7283
+ if (!deps.isTTY || !deps.promptLine)
7284
+ throw new ModernInstallRecoveryError("Modern invite creation requires --display-name in a non-interactive terminal.");
7285
+ // BAPI-1448: a valid folder name is the label, with no question. The prompt
7286
+ // survives only for a folder name the validator rejects.
7287
+ let suggestion = "";
7288
+ try {
7289
+ suggestion = validateBootstrapDisplayName(path.basename(folder));
7290
+ }
7291
+ catch { /* explicit label required */ }
7292
+ if (suggestion) {
7293
+ label = suggestion;
7294
+ }
7295
+ else {
7296
+ label = validateBootstrapDisplayName(await deps.promptLine("Project display name: "));
7297
+ }
7298
+ }
7299
+ if (selfServe) {
7300
+ if (!deps.isTTY || !deps.promptLine)
7301
+ throw new ModernInstallRecoveryError("Self-serve email verification requires an interactive terminal.");
7302
+ if (!record) {
7303
+ const intent = await prepareModernBootstrapSignup(scope, label, signupEmail, () => generateBootstrapKeySecret(deps.randomBytes), writeDeps);
7304
+ if (!intent.ok || !intent.record)
7305
+ throw new Error(intent.ok ? "Signup proof missing." : intent.error);
7306
+ record = intent.record;
7307
+ const requested = await requestSelfServeChallenge(deps, baseUrl, signupEmail);
7308
+ if (!requested.ok)
7309
+ throw new ModernInstallRecoveryError("Signup handle could not be established. Preserve recovery proof; contact installation support before restarting signup.");
7310
+ const saved = await mutateModernBootstrapRecord(scope, writeDeps, current => {
7311
+ if (!current)
7312
+ throw new ModernInstallRecoveryError("Signup proof missing.");
7313
+ return { ...current, challengeHandle: requested.handle, challengeExpiresAt: requested.expiresAt };
7314
+ });
7315
+ if (!saved.ok || !saved.record)
7316
+ throw new Error(saved.ok ? "Signup proof missing." : saved.error);
7317
+ record = saved.record;
7318
+ }
7319
+ if (record.replayToken && record.fingerprint) {
7320
+ token = record.replayToken;
7321
+ fingerprint = record.fingerprint;
7322
+ scope = makeScope(`bootstrap-pending:invite:${fingerprint}`);
7323
+ }
7324
+ else {
7325
+ if (!record.challengeHandle || !record.challengeExpiresAt)
7326
+ throw new ModernInstallRecoveryError("The previous signup response is unresolved. Preserve proof and contact installation support before restarting.");
7327
+ if (Date.parse(record.challengeExpiresAt) <= deps.now())
7328
+ throw new ModernInstallRecoveryError("The saved verification challenge expired. Preserve proof and contact installation support.");
7329
+ logVerificationCodeLeadIn(deps.log, record.signupEmail ?? signupEmail);
7330
+ const code = await promptForVerificationCode(deps, deps.log);
7331
+ if (!code)
7332
+ throw new ModernInstallRecoveryError("Verification incomplete. Resume the same signup from this folder.");
7333
+ const verified = await verifySelfServeChallenge(deps, baseUrl, record.challengeHandle, code);
7334
+ if (!verified.ok)
7335
+ throw new ModernInstallRecoveryError("Verification did not complete. Preserve proof and resume the same signup.");
7336
+ token = verified.token;
7337
+ fingerprint = fingerprintBootstrapInvite(token, baseUrl);
7338
+ const inviteScope = makeScope(`bootstrap-pending:invite:${fingerprint}`);
7339
+ const transitioned = await transitionModernBootstrapSignup(makeScope(signupLocator), inviteScope, fingerprint, token, record.challengeHandle, writeDeps);
7340
+ if (!transitioned.ok || !transitioned.record)
7341
+ throw new Error(transitioned.ok ? "Signup proof missing." : transitioned.error);
7342
+ record = transitioned.record;
7343
+ scope = inviteScope;
7344
+ }
7345
+ }
7346
+ else {
7347
+ const prepared = await prepareModernBootstrapInvite(scope, fingerprint, label, () => generateBootstrapKeySecret(deps.randomBytes), writeDeps);
7348
+ if (!prepared.ok || !prepared.record)
7349
+ throw new Error(prepared.ok ? "Invite proof missing." : prepared.error);
7350
+ record = prepared.record;
7351
+ }
7352
+ if (!record)
7353
+ throw new ModernInstallRecoveryError("Recovery proof missing.");
7354
+ if (resumed)
7355
+ deps.log("Resuming the original installation; changing the entered label does not rename its project.");
7356
+ if (record.canonical && record.serverLabel) {
7357
+ const ping = await verifyConnectivity(deps, baseUrl, record.canonical, record.keySecret);
7358
+ if (!ping.ok)
7359
+ throw new ModernInstallRecoveryError("Canonical connectivity failed. Preserve proof and resume the same attempt.");
7360
+ return { scope, record, repoName: record.canonical, displayName: record.serverLabel };
7361
+ }
7362
+ let exchange = await exchangeBootstrapInvite(deps, baseUrl, token, { repoDisplayName: record.label }, record.keySecret);
7363
+ if (!exchange.ok && exchange.kind === "email-required") {
7364
+ const email = deps.isTTY ? await promptForBoundedEmail(deps, INVITE_EMAIL_PROMPT) : undefined;
7365
+ if (!email)
7366
+ throw new ModernInstallRecoveryError("An email is required; resume the same invite in an interactive terminal.");
7367
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, token, { repoDisplayName: record.label }, record.keySecret, email);
7368
+ }
7369
+ if (!exchange.ok && exchange.message.includes("Creation outcome is unknown"))
7370
+ throw new ModernInstallRecoveryError("Creation outcome is unknown. Preserve recovery proof and rerun the same attempt from the original folder and invite/signup.");
7371
+ if (!exchange.ok || !exchange.displayName)
7372
+ 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.");
7373
+ const checkpoint = await checkpointModernBootstrapIdentity(scope, record, exchange.repoName, exchange.displayName, writeDeps);
7374
+ if (!checkpoint.ok || !checkpoint.record)
7375
+ throw new Error(checkpoint.ok ? "Checkpoint missing." : checkpoint.error);
7376
+ const ping = await verifyConnectivity(deps, baseUrl, exchange.repoName, record.keySecret);
7377
+ if (!ping.ok)
7378
+ throw new ModernInstallRecoveryError("Canonical connectivity failed. Preserve proof and resume the same attempt.");
7379
+ return { scope, record: checkpoint.record, repoName: exchange.repoName, displayName: exchange.displayName };
7380
+ }