@bridge_gpt/mcp-server 0.2.59 → 0.2.62

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 (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -162,11 +162,16 @@ import { resolveAgentNotesTargets, writeAgentNotes, } from "./agent-notes.js";
162
162
  // `executor install-service` as a subprocess, which would lose the typed plan,
163
163
  // the write outcome, and the unit path this flow has to record.
164
164
  import { collectExecutorInstallPreflight, } from "./executor/install-preflight.js";
165
- import { inspectExecutorServiceState, startExecutorService, } from "./executor/service-lifecycle.js";
166
- import { executorLaunchdLabelForId, executorSystemdUnitNameForId, executorLaunchdPlistPathForId, executorSystemdUnitPathForId, resolvePackagedExecutorInvocation, writeExecutorServicePlan, } from "./executor/service-unit.js";
165
+ import { inspectExecutorServiceDurability, inspectExecutorServiceState, startExecutorService, } from "./executor/service-lifecycle.js";
166
+ import { executorLaunchdLabelForId, executorSystemdUnitNameForId, executorLaunchdPlistPathForId, executorSystemdUnitPathForId, resolvePackagedExecutorInvocation, writeExecutorServicePlan, EXECUTOR_SERVICE_UNIT_FILE_MODE, } from "./executor/service-unit.js";
167
167
  import { collectConductorNativeLedgerSafe, } from "./conductor/doctor.js";
168
168
  import { installConductorGitHooks, } from "./conductor/git-hooks.js";
169
169
  import { runInstallBridgeConductorCli, } from "./install-bridge-conductor.js";
170
+ // BAPI-1136: the merge-workflow generate-and-PR installer. Domain logic lives in
171
+ // its own module; this file supplies the parser and the process seams.
172
+ import { runMergeWorkflowInstall, } from "./merge-workflow-install.js";
173
+ import { MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
174
+ import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
170
175
  import { runConductorInstallDoctor, EPIC_IMPLEMENTER_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
171
176
  import { resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
172
177
  import { claudeReviewWorkflowPath, writeClaudeReviewWorkflow, } from "./claude-review-workflow.js";
@@ -320,7 +325,11 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
320
325
  "do' line. The server decides eligibility (repository state AND whether this caller can run the " +
321
326
  "command), so do NOT apply a role rule or a learned-state rule of your own. Do NOT run the " +
322
327
  "returned command yourself — only recommend it; running it is the human's next explicit " +
323
- "invocation. For supporting context around a recommended command, use the manifest's own " +
328
+ "invocation. When you recommend a command and `next_step.command_context` is a non-empty " +
329
+ "string, print it verbatim on its own line directly under the command — it is the server's " +
330
+ "explanation of what the command gives the human and how long it takes; do not reword, shorten, " +
331
+ "or extend it, and render nothing in its place when it is empty or missing. " +
332
+ "For other supporting context around a recommended command, use the manifest's own " +
324
333
  "next_step.post_install_indexing and next_step.post_install_scheduler text rather than inventing " +
325
334
  "an explanation for it. " +
326
335
  "Never request, echo, or transport any credential — only ever direct the human to that " +
@@ -589,6 +598,26 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
589
598
  " executor is actually observed. See",
590
599
  " `install conductor --help`.",
591
600
  "",
601
+ " install conductor --merge-workflow --codeowner <@org/team|@user>",
602
+ " Generate the Epic Implementer merge workflow",
603
+ " plus its CODEOWNERS fragment and repository-rule",
604
+ " guidance, commit them on a managed branch, push",
605
+ " with your own git, and open a pull request with",
606
+ " your own `gh` (BAPI-1136). It NEVER merges that",
607
+ " pull request and Bridge performs no repository",
608
+ " write. A complete read-only preflight runs before",
609
+ " the first branch, file, commit, push, or pull",
610
+ " request. Re-running reports one of four states —",
611
+ " not installed, managed version is older,",
612
+ " identical, or modified/unrecognized — and an",
613
+ " already-open installation or upgrade pull request",
614
+ " is reported rather than duplicated. A modified or",
615
+ " unrecognized workflow is never overwritten. No",
616
+ " credential is ever accepted, prompted for, or",
617
+ " written: the repository Actions secret is named",
618
+ " only, and you create it yourself afterwards.",
619
+ " `--dry-run` performs NO write of any kind.",
620
+ "",
592
621
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
593
622
  "(default docs/tmp) are read from the environment with the shown fallbacks.",
594
623
  "BAPI_SIGNUP_EMAIL supplies the self-serve signup email non-interactively (it is",
@@ -960,6 +989,10 @@ export const INSTALL_BRIDGE_BITBUCKET_OFFER_CONTEXT = [
960
989
  "Step 4b — optional: connect Bitbucket.",
961
990
  " This installs the Bridge Bitbucket Forge app so you can implement and review tickets with Bridge.",
962
991
  " It opens Bitbucket in your browser; no Bitbucket credential is shared with Bridge.",
992
+ // BAPI-1247: name the destination precisely. "Admin panel" sent people looking for a
993
+ // page that does not exist under that name — the app lives under the workspace's own
994
+ // settings, and saying so is the difference between a 20-second step and a hunt.
995
+ ' You finish by pasting a printed code into Workspace settings → Apps → "Connect Bitbucket to Bridge API".',
963
996
  ` You can do this later instead: ${INSTALL_BRIDGE_CONNECT_BITBUCKET_COMMAND}`,
964
997
  ];
965
998
  /** Default-No Bitbucket offer prompt. Mirrors {@link INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT}. */
@@ -1024,10 +1057,30 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1024
1057
  if (answer !== "y" && answer !== "yes")
1025
1058
  return; // declining is a normal outcome
1026
1059
  const connectDeps = createDefaultConnectBitbucketDeps();
1027
- const code = await runBitbucketConnectionFlow(connectDeps, api, repoName);
1028
- if (code !== 0) {
1029
- log(" note: Bitbucket was not connected. Your install is complete — connect Bitbucket later with " +
1030
- `'npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo ${repoName}'.`);
1060
+ const result = await runBitbucketConnectionFlow(connectDeps, api, repoName, {
1061
+ // The install path has no workspace slug to give: a workspace is only discoverable
1062
+ // AFTER redemption stages candidates, which is later than this. The connection flow
1063
+ // falls back to its textual navigation path rather than guessing a link.
1064
+ workspace: undefined,
1065
+ // The Step 4b prompt just above IS the approval for the first mint. Asking again
1066
+ // inside the flow would be a second confirmation for one decision.
1067
+ initialMintApproved: true,
1068
+ });
1069
+ if (result.kind === "connected") {
1070
+ // BAPI-1171, mirroring offerGithubConnection: only a CONFIRMED success marks the
1071
+ // run's VCS provider as connected.
1072
+ session.vcsConnected = true;
1073
+ }
1074
+ else {
1075
+ // BAPI-1247: close Bitbucket out loud before Step 4c starts. Step 4c is about
1076
+ // agent notes and has nothing to do with Bitbucket, so without this line a user
1077
+ // who declined a renewal sees the install move on and cannot tell whether the
1078
+ // Bitbucket step succeeded, failed, or is still going.
1079
+ log(" note: Bitbucket connection was not completed. Your install is complete and will continue — " +
1080
+ // Single-quoted repo name, matching the connect command's own resume form, so
1081
+ // a name with spaces or shell metacharacters is inert when pasted.
1082
+ `connect Bitbucket later with npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo '${repoName}' ` +
1083
+ "(this issues a new code).");
1031
1084
  }
1032
1085
  }
1033
1086
  catch {
@@ -1036,21 +1089,59 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1036
1089
  `Bitbucket later with 'npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo ${repoName}'.`);
1037
1090
  }
1038
1091
  }
1039
- /** Default-No agent-notes offer prompt. Mirrors {@link INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT}. */
1040
- export const INSTALL_BRIDGE_AGENT_NOTES_PROMPT = "Add cross-platform agent notes? [y/N]: ";
1092
+ /**
1093
+ * The exact agent-notes consent-prompt prefix, shared with tests so the copy
1094
+ * cannot drift. Mirrors {@link INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX}: the
1095
+ * prefix is pinned, the platform tail varies with the selection.
1096
+ */
1097
+ export const INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX = "Write guidance so ";
1098
+ /**
1099
+ * Describe the selected notes platforms for the offer copy: a naturally joined
1100
+ * subject and whether it is plural (so a verb can agree). `platformLabels` are
1101
+ * the display names of the SELECTED platforms that resolve to a writable notes
1102
+ * target (Windsurf, which has no convention, never appears). An empty list —
1103
+ * not reached while a live offer runs, since the offer self-gates on a writable
1104
+ * target — falls back to a generic, singular "your coding agent".
1105
+ */
1106
+ function describeAgentNotesPlatforms(platformLabels) {
1107
+ if (platformLabels.length === 0)
1108
+ return { subject: "your coding agent", plural: false };
1109
+ return { subject: joinLabelsWithAnd(platformLabels), plural: platformLabels.length > 1 };
1110
+ }
1111
+ /** Join labels for prose: "A", "A and B", or "A, B, and C". */
1112
+ function joinLabelsWithAnd(labels) {
1113
+ if (labels.length <= 1)
1114
+ return labels[0] ?? "";
1115
+ if (labels.length === 2)
1116
+ return `${labels[0]} and ${labels[1]}`;
1117
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1118
+ }
1119
+ /**
1120
+ * The default-No agent-notes consent prompt (BAPI-1207; payoff-led, platform-named
1121
+ * copy per the BAPI-1239 install-issue #4 follow-up). Leads with WHY — teaching
1122
+ * the agent how to use Bridge's MCP tools — and NAMES the selected platform(s)
1123
+ * rather than the old generic "cross-platform" phrasing.
1124
+ */
1125
+ export function buildAgentNotesPrompt(platformLabels) {
1126
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1127
+ return `${INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX}${subject} know${plural ? "" : "s"} how to use the Bridge MCP? [y/N]: `;
1128
+ }
1041
1129
  /**
1042
1130
  * The offer-context lines printed immediately before the agent-notes prompt
1043
- * (BAPI-1207). Names the applicable file(s) and states the marker-scoped,
1044
- * non-destructive contract up front — mirrors the GitHub/Bitbucket offers'
1045
- * "explain before asking" convention.
1131
+ * (BAPI-1207). Leads with the payoff — teaching the named platform(s) WHEN to
1132
+ * reach for Bridge's MCP tools — then states the marker-scoped, non-destructive
1133
+ * contract as a secondary line, mirroring the GitHub/Bitbucket offers' "explain
1134
+ * before asking" convention.
1046
1135
  */
1047
- export function buildAgentNotesOfferContext(targets) {
1136
+ export function buildAgentNotesOfferContext(targets, platformLabels) {
1137
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1048
1138
  return [
1049
1139
  "",
1050
- "Step 4c — optional: cross-platform agent notes.",
1051
- ` This creates or refreshes a Bridge-owned region in ${targets.join(" and ")} with situation-`,
1052
- " to-tool guidance for a few Bridge MCP tools. Only that marked region is created or",
1053
- " refreshed — any existing content in the file, inside or outside it, is otherwise preserved.",
1140
+ `Step 4c — optional: Bridge MCP guidance for ${subject}.`,
1141
+ ` Write guidance so ${subject} know${plural ? "" : "s"} when to reach for Bridge's MCP tools`,
1142
+ " during a task — for project standards, a second opinion, a council, or a ticket review.",
1143
+ ` It writes a Bridge-owned region to ${targets.join(" and ")}; only that marked region is`,
1144
+ " created or refreshed — any existing content, inside or outside it, is otherwise preserved.",
1054
1145
  ];
1055
1146
  }
1056
1147
  /**
@@ -1064,16 +1155,16 @@ export function buildAgentNotesOfferContext(targets) {
1064
1155
  * actual write via {@link writeAgentNotes}, so a `--dry-run` (which never
1065
1156
  * reaches this call) and the live path share exactly one write path.
1066
1157
  */
1067
- export async function offerAgentGuidanceNotes(deps, log, targets) {
1158
+ export async function offerAgentGuidanceNotes(deps, log, targets, platformLabels) {
1068
1159
  if (targets.length === 0)
1069
1160
  return { kind: "not-applicable" };
1070
1161
  // No prompt surface → no offer. Never assume consent on a non-interactive run.
1071
1162
  if (!deps.isTTY || !deps.promptLine)
1072
1163
  return { kind: "headless-skip" };
1073
1164
  try {
1074
- for (const line of buildAgentNotesOfferContext(targets))
1165
+ for (const line of buildAgentNotesOfferContext(targets, platformLabels))
1075
1166
  log(line);
1076
- const answer = (await deps.promptLine(INSTALL_BRIDGE_AGENT_NOTES_PROMPT)).trim().toLowerCase();
1167
+ const answer = (await deps.promptLine(buildAgentNotesPrompt(platformLabels))).trim().toLowerCase();
1077
1168
  // Only an explicit yes proceeds — blank input, EOF, "n", and anything malformed
1078
1169
  // all decline without writing.
1079
1170
  return answer === "y" || answer === "yes" ? { kind: "consented" } : { kind: "declined" };
@@ -1374,6 +1465,160 @@ function defaultShellFreeRunner(executable, args) {
1374
1465
  child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1375
1466
  });
1376
1467
  }
1468
+ export function parseMergeWorkflowInstallArgs(argv) {
1469
+ if (!argv.some((a) => a === "--merge-workflow" || a.startsWith("--merge-workflow="))) {
1470
+ return { status: "not-selected" };
1471
+ }
1472
+ let codeOwner;
1473
+ let dryRun = false;
1474
+ let error = null;
1475
+ const setError = (message) => {
1476
+ if (error === null)
1477
+ error = message;
1478
+ };
1479
+ for (let i = 0; i < argv.length; i++) {
1480
+ const raw = argv[i];
1481
+ const eq = raw.indexOf("=");
1482
+ const flag = eq === -1 ? raw : raw.slice(0, eq);
1483
+ const inline = eq === -1 ? undefined : raw.slice(eq + 1);
1484
+ switch (flag) {
1485
+ case "--merge-workflow":
1486
+ if (inline !== undefined)
1487
+ setError("--merge-workflow takes no value");
1488
+ break;
1489
+ case "--dry-run":
1490
+ if (inline !== undefined)
1491
+ setError("--dry-run takes no value");
1492
+ dryRun = true;
1493
+ break;
1494
+ case "--codeowner": {
1495
+ const next = argv[i + 1];
1496
+ if (inline !== undefined) {
1497
+ if (inline.trim().length === 0)
1498
+ setError("--codeowner requires a value");
1499
+ else
1500
+ codeOwner = inline.trim();
1501
+ }
1502
+ else if (next === undefined || next.startsWith("-") || next.trim().length === 0) {
1503
+ setError("--codeowner requires a value");
1504
+ }
1505
+ else {
1506
+ codeOwner = next.trim();
1507
+ i++;
1508
+ }
1509
+ break;
1510
+ }
1511
+ default:
1512
+ setError(`Unknown option: ${flag}`);
1513
+ }
1514
+ }
1515
+ if (error === null && codeOwner === undefined) {
1516
+ error = "--codeowner <@org/team|@user> is required with --merge-workflow";
1517
+ }
1518
+ return {
1519
+ status: "selected",
1520
+ error,
1521
+ options: { codeOwner: codeOwner ?? "", dryRun },
1522
+ };
1523
+ }
1524
+ /** Usage text for the merge-workflow installer. */
1525
+ export function getMergeWorkflowInstallUsage() {
1526
+ return [
1527
+ `Usage: ${MERGE_WORKFLOW_REMEDY_COMMAND} --codeowner <@org/team|@user> [--dry-run]`,
1528
+ "",
1529
+ "Generate the Epic Implementer merge workflow plus its CODEOWNERS fragment and",
1530
+ "repository-rule guidance, commit them on a managed branch, push with your own",
1531
+ "git identity, and open a pull request with your own `gh`.",
1532
+ "",
1533
+ "Options:",
1534
+ " --merge-workflow Select this command.",
1535
+ " --codeowner <@owner> REQUIRED. The one @user or @org/team that should own",
1536
+ " review of the workflow path. Not inferred.",
1537
+ " --dry-run Print the plan and perform NO write: no branch, no",
1538
+ " file, no install-state write, no commit, no push,",
1539
+ " and no pull request.",
1540
+ "",
1541
+ "It never merges the pull request and never asks for a credential. The Bridge",
1542
+ `API key is named only (${MERGE_WORKFLOW_API_KEY_SECRET_NAME}); create that`,
1543
+ "repository Actions secret yourself after the pull request lands.",
1544
+ ].join("\n");
1545
+ }
1546
+ /**
1547
+ * Build the installer's seams from the final merged installer deps.
1548
+ *
1549
+ * `git` and `gh` run through {@link defaultShellFreeRunner} — argument arrays,
1550
+ * `shell: false` — and `gh` additionally receives the non-interactive pins so a
1551
+ * prompt can never hang an install. The resulting environment is never printed.
1552
+ */
1553
+ export function buildMergeWorkflowInstallDeps(deps, log, errorLog) {
1554
+ const toResult = (r) => ({
1555
+ status: r.exitCode,
1556
+ stdout: r.stdout,
1557
+ stderr: r.stderr,
1558
+ });
1559
+ return {
1560
+ cwd: deps.cwd,
1561
+ log,
1562
+ errorLog,
1563
+ readFile: deps.readFile,
1564
+ writeFile: deps.writeFile,
1565
+ mkdir: deps.mkdir,
1566
+ runGit: deps.mergeWorkflowRunGit ??
1567
+ (async (args) => toResult(await defaultShellFreeRunner("git", args))),
1568
+ runGh: deps.mergeWorkflowRunGh ??
1569
+ (async (args) => toResult(await defaultShellFreeRunnerWithEnv("gh", args, {
1570
+ GH_PROMPT_DISABLED: "1",
1571
+ GH_NO_UPDATE_NOTIFIER: "1",
1572
+ }))),
1573
+ confirmWrite: deps.isTTY && deps.promptLine
1574
+ ? async (promptText) => {
1575
+ const answer = (await deps.promptLine(promptText)).trim().toLowerCase();
1576
+ return answer === "y" || answer === "yes";
1577
+ }
1578
+ : undefined,
1579
+ recordArtifact: (cwd, relPath) => recordInstalledProjectArtifact(cwd, relPath, {
1580
+ readFile: deps.readFile,
1581
+ writeFile: (p, data) => deps.writeFile(p, data),
1582
+ rename: deps.rename,
1583
+ mkdir: (p, o) => deps.mkdir(p, o),
1584
+ unlink: deps.unlink,
1585
+ }),
1586
+ };
1587
+ }
1588
+ /**
1589
+ * {@link defaultShellFreeRunner} with extra environment entries overlaid.
1590
+ *
1591
+ * Kept separate rather than widening the shared runner's signature: every other
1592
+ * caller wants the inherited environment exactly as it is, and an optional
1593
+ * parameter that only one caller passes is an invitation to pass it wrongly.
1594
+ *
1595
+ * NOTE ON THE INHERITED ENVIRONMENT (BAPI-1136). This spread is deliberate and
1596
+ * is NOT the ambient-identity pattern this ticket removes from the merge path.
1597
+ * The two are opposites: a merge must authenticate as one explicitly configured
1598
+ * credential, because it performs a privileged write on Bridge's behalf; this
1599
+ * `gh` opens a pull request AS THE CUSTOMER, using their own login, which is
1600
+ * exactly what makes the resulting review theirs. `conductor/local-merge.ts`'s
1601
+ * allowlisted child environment is where the other rule lives.
1602
+ */
1603
+ function defaultShellFreeRunnerWithEnv(executable, args, extraEnv) {
1604
+ return new Promise((resolve, reject) => {
1605
+ const child = spawn(executable, args, {
1606
+ shell: false,
1607
+ stdio: ["ignore", "pipe", "pipe"],
1608
+ env: { ...process.env, ...extraEnv },
1609
+ });
1610
+ let stdout = "";
1611
+ let stderr = "";
1612
+ child.stdout?.on("data", (chunk) => {
1613
+ stdout += chunk.toString("utf-8");
1614
+ });
1615
+ child.stderr?.on("data", (chunk) => {
1616
+ stderr += chunk.toString("utf-8");
1617
+ });
1618
+ child.on("error", reject);
1619
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1620
+ });
1621
+ }
1377
1622
  /** Presence probe: `<tool> --version` exiting 0 means the tool resolves on PATH. */
1378
1623
  async function defaultExecutablePresenceProbe(name) {
1379
1624
  try {
@@ -1393,7 +1638,18 @@ async function defaultExecutablePresenceProbe(name) {
1393
1638
  * stays in `install-bridge-conductor.ts`; moving it into this dispatch layer
1394
1639
  * would put policy in the place least able to test it.
1395
1640
  */
1396
- function buildConductorExecutorDeps(deps, baseUrl) {
1641
+ /**
1642
+ * Compose the executor-provisioning bundle from the FINAL merged deps.
1643
+ *
1644
+ * EXPORTED for testing (BAPI-1137). The bundle's seams are only reachable
1645
+ * through the nested conductor runner after two consents and a successful
1646
+ * start, so driving the whole CLI to assert "the durability probe got the
1647
+ * injected uid" turns a one-line property into an end-to-end rehearsal. Every
1648
+ * entry here is a thin adapter over an implementation that is tested elsewhere;
1649
+ * what this function decides is only WHICH seam each one is wired to, and that
1650
+ * is exactly what a direct test can check.
1651
+ */
1652
+ export function buildConductorExecutorDeps(deps, baseUrl) {
1397
1653
  const homeDir = deps.homedir();
1398
1654
  const lookupExecutable = deps.lookupExecutable ?? defaultExecutablePresenceProbe;
1399
1655
  const runLifecycleCommand = deps.runLifecycleCommand ?? defaultShellFreeRunner;
@@ -1416,7 +1672,14 @@ function buildConductorExecutorDeps(deps, baseUrl) {
1416
1672
  writeUnit: (plan) => writeExecutorServicePlan(plan, {
1417
1673
  stat: (filePath) => deps.stat(filePath),
1418
1674
  mkdir: (dirPath, options) => deps.mkdir(dirPath, options),
1419
- writeFile: (filePath, content) => deps.writeFile(filePath, content),
1675
+ // BAPI-1137: the write mode is passed through EXPLICITLY rather than
1676
+ // left to the process umask — a unit names the repositories and endpoint
1677
+ // this host serves, and how readable it ends up must not depend on the
1678
+ // shell that happened to run the installer.
1679
+ writeFile: (filePath, content, options) => deps.writeFile(filePath, content, options ?? { encoding: "utf-8", mode: EXECUTOR_SERVICE_UNIT_FILE_MODE }),
1680
+ // The permission seam, so an ALREADY-PRESENT unit's mode is corrected
1681
+ // too: `writeFile`'s mode only applies at creation.
1682
+ chmod: deps.chmod,
1420
1683
  // Supplying `readFile` is what enables the `unchanged` outcome, so a
1421
1684
  // re-run against an identical unit reports the truth instead of an
1422
1685
  // unnecessary rewrite.
@@ -1433,6 +1696,29 @@ function buildConductorExecutorDeps(deps, baseUrl) {
1433
1696
  return result.ok ? { ok: true } : { ok: false, error: result.error };
1434
1697
  },
1435
1698
  startService: (plan) => startExecutorService(plan, runLifecycleCommand),
1699
+ // BAPI-1137 — both new seams are THIN adapters over `service-lifecycle.ts`,
1700
+ // exactly like `startService` above. No probe is built here, no command
1701
+ // string is assembled here, and no policy about what a state means lives
1702
+ // here: this file stays a dispatch layer, and the module that owns
1703
+ // launchctl/systemctl/loginctl keeps owning them.
1704
+ inspectServiceState: (plan) => inspectExecutorServiceState({
1705
+ platform: plan.platform,
1706
+ unitPath: plan.unitPath,
1707
+ serviceIdentifier: plan.serviceIdentifier,
1708
+ // The same injected `getuid` seam the start plan uses — the launchd
1709
+ // domain target needs it, and reading it here rather than inside the
1710
+ // lifecycle module keeps that module free of ambient process state.
1711
+ uid: deps.getuid ? deps.getuid() : null,
1712
+ }, runLifecycleCommand),
1713
+ inspectDurability: (plan) => inspectExecutorServiceDurability({
1714
+ platform: plan.platform,
1715
+ unitPath: plan.unitPath,
1716
+ serviceIdentifier: plan.serviceIdentifier,
1717
+ // Linux lingering is a property of the USER (`loginctl show-user
1718
+ // <uid>`) and the macOS login domain is `gui/<uid>`, so both probes
1719
+ // need the numeric uid from this same seam.
1720
+ uid: deps.getuid ? deps.getuid() : null,
1721
+ }, runLifecycleCommand),
1436
1722
  now: deps.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)),
1437
1723
  sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1438
1724
  };
@@ -1661,21 +1947,34 @@ export async function resolveSignupEmail(options, deps) {
1661
1947
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
1662
1948
  return { ok: true, value: fromEnv.trim() };
1663
1949
  }
1950
+ const entered = await promptForBoundedEmail(deps, "Email for Bridge workspace setup (required): ");
1951
+ if (entered === undefined)
1952
+ return { ok: false, error: "No email entered." };
1953
+ return { ok: true, value: entered };
1954
+ }
1955
+ /**
1956
+ * The bounded interactive email prompt shared by self-serve signup
1957
+ * ({@link resolveSignupEmail}) and invite redemption (BAPI-1263). Two reads at
1958
+ * most: the first answer, then ONE re-prompt after a single hint line. EOF
1959
+ * resolves as an empty value (promptLineViaReadline resolves "" on close), which
1960
+ * fails the shape check like any other blank answer, so it costs one re-prompt and
1961
+ * then fails cleanly rather than hanging. Returns the trimmed (case-preserved)
1962
+ * address, or `undefined` after two invalid answers. The answer is never echoed.
1963
+ * The caller guarantees `deps.promptLine` exists.
1964
+ */
1965
+ async function promptForBoundedEmail(deps, promptText) {
1664
1966
  const promptLine = deps.promptLine;
1665
- // BAPI-1171: bounded to two reads total — the first answer, then ONE re-prompt
1666
- // after a single hint line. EOF resolves as an empty value (promptLineViaReadline
1667
- // resolves "" on close), which fails the shape check like any other blank
1668
- // answer, so it costs one re-prompt and then fails cleanly rather than hanging.
1967
+ if (!promptLine)
1968
+ return undefined;
1669
1969
  for (let attempt = 0; attempt < 2; attempt += 1) {
1670
- const raw = await promptLine("Email for Bridge workspace setup (required): ");
1970
+ const raw = await promptLine(promptText);
1671
1971
  const entered = String(raw ?? "").trim();
1672
- if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(entered)) {
1673
- return { ok: true, value: entered };
1674
- }
1972
+ if (INSTALL_BRIDGE_SIGNUP_EMAIL_SHAPE.test(entered))
1973
+ return entered;
1675
1974
  if (attempt === 0)
1676
1975
  deps.log(INSTALL_BRIDGE_SIGNUP_EMAIL_HINT);
1677
1976
  }
1678
- return { ok: false, error: "No email entered." };
1977
+ return undefined;
1679
1978
  }
1680
1979
  /**
1681
1980
  * Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
@@ -3146,15 +3445,21 @@ export function buildBootstrapExchangeUrl(baseUrl) {
3146
3445
  * POST the bootstrap-invite exchange. The token and `key_secret` travel ONLY in
3147
3446
  * the JSON request body (never a query string, never a header, never a log line).
3148
3447
  * Bounded by the same 10s timeout as the connectivity ping.
3448
+ *
3449
+ * BAPI-1263: an optional nonempty `email` is appended as a fourth body field, used
3450
+ * only on the retry after an `email-required` refusal. Omitted or empty, the body is
3451
+ * the legacy three-field one, byte for byte.
3149
3452
  */
3150
- export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret) {
3453
+ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, keySecret, email) {
3151
3454
  const url = buildBootstrapExchangeUrl(baseUrl);
3152
3455
  let resp;
3153
3456
  try {
3154
3457
  resp = await deps.fetch(url, {
3155
3458
  method: "POST",
3156
3459
  headers: { "Content-Type": "application/json" },
3157
- body: JSON.stringify({ token, repo_name: repoName, key_secret: keySecret }),
3460
+ body: JSON.stringify(email
3461
+ ? { token, repo_name: repoName, key_secret: keySecret, email }
3462
+ : { token, repo_name: repoName, key_secret: keySecret }),
3158
3463
  signal: AbortSignal.timeout(10_000),
3159
3464
  });
3160
3465
  }
@@ -3208,12 +3513,42 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
3208
3513
  if (resp.status === 401) {
3209
3514
  return { ok: false, kind: "invalid-invite", message: `The Bridge API rejected the bootstrap invite (HTTP ${resp.status}).` };
3210
3515
  }
3516
+ if (resp.status === 426) {
3517
+ // Only the exact `email_required` refusal is recognized; any other, absent, or
3518
+ // unreadable 426 body stays on the generic failure path below. The message is
3519
+ // installer-owned — nothing from the response is propagated.
3520
+ let recognized = false;
3521
+ try {
3522
+ const body = (await resp.json());
3523
+ recognized = body?.detail?.error === "email_required";
3524
+ }
3525
+ catch {
3526
+ recognized = false;
3527
+ }
3528
+ if (recognized) {
3529
+ return {
3530
+ ok: false,
3531
+ kind: "email-required",
3532
+ message: "The Bridge API requires an email address to redeem this invite.",
3533
+ };
3534
+ }
3535
+ }
3211
3536
  return {
3212
3537
  ok: false,
3213
3538
  kind: "failed",
3214
3539
  message: `The bootstrap exchange failed (HTTP ${resp.status}). Verify BAPI_BASE_URL and try again.`,
3215
3540
  };
3216
3541
  }
3542
+ /** The invite-email prompt (BAPI-1263), shown once after an `email_required` refusal. */
3543
+ const INVITE_EMAIL_PROMPT = "Email for this Bridge invite (required): ";
3544
+ /** BAPI-1263: fixed, secret-free failures for the invite email recovery. */
3545
+ export const INVITE_EMAIL_REQUIRED_NON_INTERACTIVE_MESSAGE = "Error: this invite needs an email address, and there is no interactive terminal to ask for it. " +
3546
+ `The bootstrap invite has NOT been used. Re-run \`npx -y ${MCP_PACKAGE_NAME} install\` in an ` +
3547
+ "interactive terminal.";
3548
+ export const INVITE_EMAIL_INVALID_ANSWERS_MESSAGE = "Error: no valid email address was entered. The bootstrap invite has NOT been used. Re-run " +
3549
+ "install and enter an address like you@example.com.";
3550
+ export const INVITE_EMAIL_REPEATED_REFUSAL_MESSAGE = "Error: the Bridge API still requires an email address after one was entered. The bootstrap " +
3551
+ "invite has NOT been used. Re-run install, or contact support if this repeats.";
3217
3552
  // ---------------------------------------------------------------------------
3218
3553
  // Self-serve mint (BAPI-618): email -> invite token
3219
3554
  // ---------------------------------------------------------------------------
@@ -4030,7 +4365,7 @@ export function buildDryRunPreview(ctx) {
4030
4365
  ...credentialStep,
4031
4366
  ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
4032
4367
  ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
4033
- ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? []) : []),
4368
+ ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? [], ctx.host.notesPlatformLabels ?? []) : []),
4034
4369
  ];
4035
4370
  }
4036
4371
  /**
@@ -4142,14 +4477,21 @@ function buildDeferredLaunchStepPreview() {
4142
4477
  * no writable target (e.g. Windsurf-only), mirroring how the GitHub offer
4143
4478
  * lines are unconditional but this offer's applicability is not.
4144
4479
  */
4145
- function buildAgentNotesPreviewLines(notesTargets) {
4480
+ function buildAgentNotesPreviewLines(notesTargets, platformLabels) {
4146
4481
  if (notesTargets.length === 0)
4147
4482
  return [];
4483
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
4148
4484
  return [
4149
- "Step 4c — optional: cross-platform agent notes (SKIPPED in --dry-run): if the terminal is",
4150
- ` interactive, offer to create or refresh a Bridge-owned marker region in ${notesTargets.join(" and ")}`,
4151
- " with situation-to-tool guidance for a few Bridge MCP tools; defaults to No, and",
4152
- " existing content outside that region is always preserved. No notes files are",
4485
+ // BAPI-1247: state the sequencing explicitly. A real run reaches this point only
4486
+ // after Step 4b has ended one way or the other, and the live run now says so out
4487
+ // loud, so the preview says so too rather than leaving the order implied.
4488
+ "Step 4c — reached only after the Step 4b VCS connect offer has completed or been",
4489
+ " explicitly deferred (a declined or incomplete connect is announced first,",
4490
+ " with the command to finish it later, and never blocks this step).",
4491
+ `Step 4c — optional: Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4492
+ ` terminal is interactive, offer to write guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4493
+ ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}; defaults`,
4494
+ " to No, and existing content outside that region is always preserved. No notes files are",
4153
4495
  " written in --dry-run.",
4154
4496
  ];
4155
4497
  }
@@ -4233,7 +4575,7 @@ function buildBootstrapDryRunPreview(ctx) {
4233
4575
  ...promoteStep,
4234
4576
  ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
4235
4577
  ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
4236
- ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? []) : []),
4578
+ ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? [], ctx.host.notesPlatformLabels ?? []) : []),
4237
4579
  ];
4238
4580
  }
4239
4581
  /**
@@ -4659,6 +5001,40 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4659
5001
  // `epic-implementer` name. This nested `install-bridge conductor` surface is
4660
5002
  // untouched by that rename.
4661
5003
  if (argv[0] === "conductor") {
5004
+ // ---- BAPI-1136: `install-bridge conductor --merge-workflow` ----------
5005
+ //
5006
+ // Intercepted ahead of `parseInstallBridgeConductorArgs` on purpose. That
5007
+ // parser REQUIRES at least one `--required-check` because an empty done gate
5008
+ // passes unconditionally — a rule that is right for the bootstrap phase and
5009
+ // meaningless for this one, which writes three files and opens a pull
5010
+ // request and has no done gate at all. Routing here keeps the bootstrap
5011
+ // parser's invariant intact instead of weakening it for a sibling command.
5012
+ //
5013
+ // It also runs BEFORE any MCP server construction or connection, and before
5014
+ // the base-URL resolution below, because this command makes no Bridge API
5015
+ // call whatsoever: it talks only to the customer's own git and `gh`.
5016
+ const mergeWorkflowArgs = parseMergeWorkflowInstallArgs(argv.slice(1));
5017
+ if (mergeWorkflowArgs.status === "selected") {
5018
+ if (mergeWorkflowArgs.error) {
5019
+ errorLog(`Error: ${mergeWorkflowArgs.error}`);
5020
+ errorLog("");
5021
+ errorLog(getMergeWorkflowInstallUsage());
5022
+ return 1;
5023
+ }
5024
+ try {
5025
+ return await runMergeWorkflowInstall(deps.cwd, mergeWorkflowArgs.options, buildMergeWorkflowInstallDeps(deps, log, errorLog));
5026
+ }
5027
+ catch {
5028
+ // Containment. A domain or subprocess exception escaping here would
5029
+ // print an unbounded message that can carry a remote URL with an
5030
+ // embedded token; the named reason plus a remediation sentence is the
5031
+ // only operator-facing surface this command has.
5032
+ errorLog("merge_workflow_install_failed");
5033
+ errorLog("The merge-workflow installer stopped unexpectedly. Re-run it; anything already " +
5034
+ "created was left in place.");
5035
+ return 1;
5036
+ }
5037
+ }
4662
5038
  if (!baseUrlResult.ok) {
4663
5039
  errorLog(baseUrlResult.error);
4664
5040
  return 1;
@@ -4783,6 +5159,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4783
5159
  let host;
4784
5160
  if (dryRunPlatforms !== undefined) {
4785
5161
  const dryRunTargets = hostConfigTargetsForPlatforms(dryRunPlatforms);
5162
+ const dryRunNotesPlan = resolveAgentNotesTargets(dryRunPlatforms);
4786
5163
  const launchDecision = resolveInstallBridgeLaunchDecision(dryRunPlatforms, options.agentName);
4787
5164
  let launch;
4788
5165
  if (launchDecision.kind === "spawn") {
@@ -4809,7 +5186,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4809
5186
  configTargets: dryRunTargets.map((t) => t.relPath),
4810
5187
  manualEditors: manualEditorNames(await detectManualEditors(deps)),
4811
5188
  launch,
4812
- notesTargets: resolveAgentNotesTargets(dryRunPlatforms).targets,
5189
+ notesTargets: dryRunNotesPlan.targets,
5190
+ notesPlatformLabels: labelsForPlatforms(dryRunNotesPlan.supportedPlatforms),
4813
5191
  };
4814
5192
  }
4815
5193
  else {
@@ -5689,12 +6067,35 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5689
6067
  // self-serve record — once more with a freshly minted token (R4-3).
5690
6068
  let exchange;
5691
6069
  let selfServeRefreshed = false;
6070
+ // BAPI-1263: the email typed after an `email_required` refusal. Run-local only —
6071
+ // never persisted, logged, or written to the pending record — and carried on
6072
+ // every later ordinary-invite exchange, including after a 409 rename.
6073
+ let inviteEmail;
5692
6074
  for (;;) {
5693
- exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
6075
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
5694
6076
  // 409 repo_name_taken: the server ROLLED BACK, so the invite is NOT consumed and
5695
6077
  // the SAME token can be retried under a new name — with the SAME pending secret,
5696
- // re-pointed to that name.
5697
- while (!exchange.ok && exchange.kind === "repo-name-taken") {
6078
+ // re-pointed to that name. The same loop handles an ordinary invite's 426
6079
+ // `email_required` (BAPI-1263), so a 409 on the email-bearing retry re-enters
6080
+ // the rename branch without an extra exchange request.
6081
+ while (!exchange.ok &&
6082
+ (exchange.kind === "repo-name-taken" ||
6083
+ (exchange.kind === "email-required" && !selfServeSignupMode))) {
6084
+ if (exchange.kind === "email-required") {
6085
+ if (inviteEmail !== undefined) {
6086
+ return fatal(INVITE_EMAIL_REPEATED_REFUSAL_MESSAGE);
6087
+ }
6088
+ if (!deps.isTTY || !deps.promptLine) {
6089
+ return fatal(INVITE_EMAIL_REQUIRED_NON_INTERACTIVE_MESSAGE);
6090
+ }
6091
+ const typedEmail = await promptForBoundedEmail(deps, INVITE_EMAIL_PROMPT);
6092
+ if (typedEmail === undefined) {
6093
+ return fatal(INVITE_EMAIL_INVALID_ANSWERS_MESSAGE);
6094
+ }
6095
+ inviteEmail = typedEmail;
6096
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
6097
+ continue;
6098
+ }
5698
6099
  if (!deps.isTTY || !deps.promptLine) {
5699
6100
  errorLog(`Error: ${exchange.message} Re-run with a different --repo (the bootstrap invite has NOT ` +
5700
6101
  "been used).");
@@ -5721,7 +6122,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5721
6122
  }
5722
6123
  repoName = nextRepo;
5723
6124
  // Same token, same key_secret — never regenerated inside the rename loop.
5724
- exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret);
6125
+ exchange = await exchangeBootstrapInvite(deps, baseUrl, inviteToken, repoName, keySecret, inviteEmail);
5725
6126
  }
5726
6127
  if (exchange.ok)
5727
6128
  break;
@@ -6088,7 +6489,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6088
6489
  // selected set. `writeAgentNotes` never touches `writeMcpInstallState` or any
6089
6490
  // other persisted install-state contract — consent is re-evaluated every run.
6090
6491
  const notesPlan = resolveAgentNotesTargets(selectedPlatforms);
6091
- const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets);
6492
+ const notesPlatformLabels = labelsForPlatforms(notesPlan.supportedPlatforms);
6493
+ const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets, notesPlatformLabels);
6092
6494
  let notesResult;
6093
6495
  let notesWriteFailed = false;
6094
6496
  if (notesOffer.kind === "consented") {