@bridge_gpt/mcp-server 0.2.59 → 0.2.60

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 (71) hide show
  1. package/README.md +173 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +4 -4
  5. package/build/conductor/bridge-api-client.js +53 -2
  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 +61 -0
  11. package/build/conductor/merge-execution-eligibility.js +129 -0
  12. package/build/conductor/readiness-sections.js +159 -2
  13. package/build/conductor/readiness.js +95 -14
  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 +16 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +183 -9
  20. package/build/drive-epic.js +23 -0
  21. package/build/epic-implementer-bin.js +10 -10
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/executor/ci-rerun-job.js +269 -0
  25. package/build/executor/cli.js +31 -0
  26. package/build/executor/credentials.js +11 -0
  27. package/build/executor/env.js +60 -0
  28. package/build/executor/index.js +21 -4
  29. package/build/executor/job-runner.js +126 -0
  30. package/build/executor/job-types.js +12 -2
  31. package/build/executor/merge-job.js +11 -2
  32. package/build/executor/service-lifecycle.js +136 -0
  33. package/build/executor/service-unit.js +427 -9
  34. package/build/executor/types.js +5 -0
  35. package/build/index.js +194 -179
  36. package/build/install-bridge-conductor.js +201 -8
  37. package/build/install-bridge.js +356 -30
  38. package/build/mcp-install-state.js +42 -4
  39. package/build/merge-pull-request.js +103 -10
  40. package/build/merge-workflow-codeowners.js +224 -0
  41. package/build/merge-workflow-config.js +133 -0
  42. package/build/merge-workflow-drift.js +66 -0
  43. package/build/merge-workflow-install.js +638 -0
  44. package/build/merge-workflow.js +572 -0
  45. package/build/pipelines.generated.js +2 -2
  46. package/build/plane/cli.js +24 -2
  47. package/build/plane/preflight.js +68 -0
  48. package/build/readme.generated.js +1 -1
  49. package/build/run-unit-tests-launcher.js +5 -0
  50. package/build/setup-epic.js +163 -9
  51. package/build/version.generated.js +3 -3
  52. package/package.json +3 -3
  53. package/build/conduct-epic/bridge-client.js +0 -773
  54. package/build/conduct-epic/checkpoint-store.js +0 -542
  55. package/build/conduct-epic/cli.js +0 -3145
  56. package/build/conduct-epic/cut-protocol.js +0 -392
  57. package/build/conduct-epic/lock.js +0 -302
  58. package/build/conduct-epic/pr-state.js +0 -286
  59. package/build/conduct-epic/spawn.js +0 -113
  60. package/build/conductor/cli.js +0 -1278
  61. package/build/conductor-bundle-cli.js +0 -256
  62. package/build/direct-ticket-tools.js +0 -70
  63. package/build/executor/reconcile-overlap-governance.js +0 -129
  64. package/build/implement-epic/bridge-client.js +0 -773
  65. package/build/implement-epic/checkpoint-store.js +0 -542
  66. package/build/implement-epic/cli.js +0 -3158
  67. package/build/implement-epic/cut-protocol.js +0 -392
  68. package/build/implement-epic/lock.js +0 -302
  69. package/build/implement-epic/pr-state.js +0 -286
  70. package/build/implement-epic/spawn.js +0 -113
  71. 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";
@@ -589,6 +594,26 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
589
594
  " executor is actually observed. See",
590
595
  " `install conductor --help`.",
591
596
  "",
597
+ " install conductor --merge-workflow --codeowner <@org/team|@user>",
598
+ " Generate the Epic Implementer merge workflow",
599
+ " plus its CODEOWNERS fragment and repository-rule",
600
+ " guidance, commit them on a managed branch, push",
601
+ " with your own git, and open a pull request with",
602
+ " your own `gh` (BAPI-1136). It NEVER merges that",
603
+ " pull request and Bridge performs no repository",
604
+ " write. A complete read-only preflight runs before",
605
+ " the first branch, file, commit, push, or pull",
606
+ " request. Re-running reports one of four states —",
607
+ " not installed, managed version is older,",
608
+ " identical, or modified/unrecognized — and an",
609
+ " already-open installation or upgrade pull request",
610
+ " is reported rather than duplicated. A modified or",
611
+ " unrecognized workflow is never overwritten. No",
612
+ " credential is ever accepted, prompted for, or",
613
+ " written: the repository Actions secret is named",
614
+ " only, and you create it yourself afterwards.",
615
+ " `--dry-run` performs NO write of any kind.",
616
+ "",
592
617
  "Environment: BAPI_BASE_URL (default https://bridgegpt-api.com) and BAPI_DOCS_DIR",
593
618
  "(default docs/tmp) are read from the environment with the shown fallbacks.",
594
619
  "BAPI_SIGNUP_EMAIL supplies the self-serve signup email non-interactively (it is",
@@ -960,6 +985,10 @@ export const INSTALL_BRIDGE_BITBUCKET_OFFER_CONTEXT = [
960
985
  "Step 4b — optional: connect Bitbucket.",
961
986
  " This installs the Bridge Bitbucket Forge app so you can implement and review tickets with Bridge.",
962
987
  " It opens Bitbucket in your browser; no Bitbucket credential is shared with Bridge.",
988
+ // BAPI-1247: name the destination precisely. "Admin panel" sent people looking for a
989
+ // page that does not exist under that name — the app lives under the workspace's own
990
+ // settings, and saying so is the difference between a 20-second step and a hunt.
991
+ ' You finish by pasting a printed code into Workspace settings → Apps → "Connect Bitbucket to Bridge API".',
963
992
  ` You can do this later instead: ${INSTALL_BRIDGE_CONNECT_BITBUCKET_COMMAND}`,
964
993
  ];
965
994
  /** Default-No Bitbucket offer prompt. Mirrors {@link INSTALL_BRIDGE_GITHUB_CONNECT_PROMPT}. */
@@ -1024,10 +1053,30 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1024
1053
  if (answer !== "y" && answer !== "yes")
1025
1054
  return; // declining is a normal outcome
1026
1055
  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}'.`);
1056
+ const result = await runBitbucketConnectionFlow(connectDeps, api, repoName, {
1057
+ // The install path has no workspace slug to give: a workspace is only discoverable
1058
+ // AFTER redemption stages candidates, which is later than this. The connection flow
1059
+ // falls back to its textual navigation path rather than guessing a link.
1060
+ workspace: undefined,
1061
+ // The Step 4b prompt just above IS the approval for the first mint. Asking again
1062
+ // inside the flow would be a second confirmation for one decision.
1063
+ initialMintApproved: true,
1064
+ });
1065
+ if (result.kind === "connected") {
1066
+ // BAPI-1171, mirroring offerGithubConnection: only a CONFIRMED success marks the
1067
+ // run's VCS provider as connected.
1068
+ session.vcsConnected = true;
1069
+ }
1070
+ else {
1071
+ // BAPI-1247: close Bitbucket out loud before Step 4c starts. Step 4c is about
1072
+ // agent notes and has nothing to do with Bitbucket, so without this line a user
1073
+ // who declined a renewal sees the install move on and cannot tell whether the
1074
+ // Bitbucket step succeeded, failed, or is still going.
1075
+ log(" note: Bitbucket connection was not completed. Your install is complete and will continue — " +
1076
+ // Single-quoted repo name, matching the connect command's own resume form, so
1077
+ // a name with spaces or shell metacharacters is inert when pasted.
1078
+ `connect Bitbucket later with npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo '${repoName}' ` +
1079
+ "(this issues a new code).");
1031
1080
  }
1032
1081
  }
1033
1082
  catch {
@@ -1036,21 +1085,59 @@ export async function offerBitbucketConnection(repoName, baseUrl, deps, log, ses
1036
1085
  `Bitbucket later with 'npx -y ${MCP_PACKAGE_NAME}@latest connect-bitbucket --repo ${repoName}'.`);
1037
1086
  }
1038
1087
  }
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]: ";
1088
+ /**
1089
+ * The exact agent-notes consent-prompt prefix, shared with tests so the copy
1090
+ * cannot drift. Mirrors {@link INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX}: the
1091
+ * prefix is pinned, the platform tail varies with the selection.
1092
+ */
1093
+ export const INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX = "Write guidance so ";
1094
+ /**
1095
+ * Describe the selected notes platforms for the offer copy: a naturally joined
1096
+ * subject and whether it is plural (so a verb can agree). `platformLabels` are
1097
+ * the display names of the SELECTED platforms that resolve to a writable notes
1098
+ * target (Windsurf, which has no convention, never appears). An empty list —
1099
+ * not reached while a live offer runs, since the offer self-gates on a writable
1100
+ * target — falls back to a generic, singular "your coding agent".
1101
+ */
1102
+ function describeAgentNotesPlatforms(platformLabels) {
1103
+ if (platformLabels.length === 0)
1104
+ return { subject: "your coding agent", plural: false };
1105
+ return { subject: joinLabelsWithAnd(platformLabels), plural: platformLabels.length > 1 };
1106
+ }
1107
+ /** Join labels for prose: "A", "A and B", or "A, B, and C". */
1108
+ function joinLabelsWithAnd(labels) {
1109
+ if (labels.length <= 1)
1110
+ return labels[0] ?? "";
1111
+ if (labels.length === 2)
1112
+ return `${labels[0]} and ${labels[1]}`;
1113
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1114
+ }
1115
+ /**
1116
+ * The default-No agent-notes consent prompt (BAPI-1207; payoff-led, platform-named
1117
+ * copy per the BAPI-1239 install-issue #4 follow-up). Leads with WHY — teaching
1118
+ * the agent how to use Bridge's MCP tools — and NAMES the selected platform(s)
1119
+ * rather than the old generic "cross-platform" phrasing.
1120
+ */
1121
+ export function buildAgentNotesPrompt(platformLabels) {
1122
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1123
+ return `${INSTALL_BRIDGE_AGENT_NOTES_PROMPT_PREFIX}${subject} know${plural ? "" : "s"} how to use the Bridge MCP? [y/N]: `;
1124
+ }
1041
1125
  /**
1042
1126
  * 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.
1127
+ * (BAPI-1207). Leads with the payoff — teaching the named platform(s) WHEN to
1128
+ * reach for Bridge's MCP tools — then states the marker-scoped, non-destructive
1129
+ * contract as a secondary line, mirroring the GitHub/Bitbucket offers' "explain
1130
+ * before asking" convention.
1046
1131
  */
1047
- export function buildAgentNotesOfferContext(targets) {
1132
+ export function buildAgentNotesOfferContext(targets, platformLabels) {
1133
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
1048
1134
  return [
1049
1135
  "",
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.",
1136
+ `Step 4c — optional: Bridge MCP guidance for ${subject}.`,
1137
+ ` Write guidance so ${subject} know${plural ? "" : "s"} when to reach for Bridge's MCP tools`,
1138
+ " during a task — for project standards, a second opinion, a council, or a ticket review.",
1139
+ ` It writes a Bridge-owned region to ${targets.join(" and ")}; only that marked region is`,
1140
+ " created or refreshed — any existing content, inside or outside it, is otherwise preserved.",
1054
1141
  ];
1055
1142
  }
1056
1143
  /**
@@ -1064,16 +1151,16 @@ export function buildAgentNotesOfferContext(targets) {
1064
1151
  * actual write via {@link writeAgentNotes}, so a `--dry-run` (which never
1065
1152
  * reaches this call) and the live path share exactly one write path.
1066
1153
  */
1067
- export async function offerAgentGuidanceNotes(deps, log, targets) {
1154
+ export async function offerAgentGuidanceNotes(deps, log, targets, platformLabels) {
1068
1155
  if (targets.length === 0)
1069
1156
  return { kind: "not-applicable" };
1070
1157
  // No prompt surface → no offer. Never assume consent on a non-interactive run.
1071
1158
  if (!deps.isTTY || !deps.promptLine)
1072
1159
  return { kind: "headless-skip" };
1073
1160
  try {
1074
- for (const line of buildAgentNotesOfferContext(targets))
1161
+ for (const line of buildAgentNotesOfferContext(targets, platformLabels))
1075
1162
  log(line);
1076
- const answer = (await deps.promptLine(INSTALL_BRIDGE_AGENT_NOTES_PROMPT)).trim().toLowerCase();
1163
+ const answer = (await deps.promptLine(buildAgentNotesPrompt(platformLabels))).trim().toLowerCase();
1077
1164
  // Only an explicit yes proceeds — blank input, EOF, "n", and anything malformed
1078
1165
  // all decline without writing.
1079
1166
  return answer === "y" || answer === "yes" ? { kind: "consented" } : { kind: "declined" };
@@ -1374,6 +1461,160 @@ function defaultShellFreeRunner(executable, args) {
1374
1461
  child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1375
1462
  });
1376
1463
  }
1464
+ export function parseMergeWorkflowInstallArgs(argv) {
1465
+ if (!argv.some((a) => a === "--merge-workflow" || a.startsWith("--merge-workflow="))) {
1466
+ return { status: "not-selected" };
1467
+ }
1468
+ let codeOwner;
1469
+ let dryRun = false;
1470
+ let error = null;
1471
+ const setError = (message) => {
1472
+ if (error === null)
1473
+ error = message;
1474
+ };
1475
+ for (let i = 0; i < argv.length; i++) {
1476
+ const raw = argv[i];
1477
+ const eq = raw.indexOf("=");
1478
+ const flag = eq === -1 ? raw : raw.slice(0, eq);
1479
+ const inline = eq === -1 ? undefined : raw.slice(eq + 1);
1480
+ switch (flag) {
1481
+ case "--merge-workflow":
1482
+ if (inline !== undefined)
1483
+ setError("--merge-workflow takes no value");
1484
+ break;
1485
+ case "--dry-run":
1486
+ if (inline !== undefined)
1487
+ setError("--dry-run takes no value");
1488
+ dryRun = true;
1489
+ break;
1490
+ case "--codeowner": {
1491
+ const next = argv[i + 1];
1492
+ if (inline !== undefined) {
1493
+ if (inline.trim().length === 0)
1494
+ setError("--codeowner requires a value");
1495
+ else
1496
+ codeOwner = inline.trim();
1497
+ }
1498
+ else if (next === undefined || next.startsWith("-") || next.trim().length === 0) {
1499
+ setError("--codeowner requires a value");
1500
+ }
1501
+ else {
1502
+ codeOwner = next.trim();
1503
+ i++;
1504
+ }
1505
+ break;
1506
+ }
1507
+ default:
1508
+ setError(`Unknown option: ${flag}`);
1509
+ }
1510
+ }
1511
+ if (error === null && codeOwner === undefined) {
1512
+ error = "--codeowner <@org/team|@user> is required with --merge-workflow";
1513
+ }
1514
+ return {
1515
+ status: "selected",
1516
+ error,
1517
+ options: { codeOwner: codeOwner ?? "", dryRun },
1518
+ };
1519
+ }
1520
+ /** Usage text for the merge-workflow installer. */
1521
+ export function getMergeWorkflowInstallUsage() {
1522
+ return [
1523
+ `Usage: ${MERGE_WORKFLOW_REMEDY_COMMAND} --codeowner <@org/team|@user> [--dry-run]`,
1524
+ "",
1525
+ "Generate the Epic Implementer merge workflow plus its CODEOWNERS fragment and",
1526
+ "repository-rule guidance, commit them on a managed branch, push with your own",
1527
+ "git identity, and open a pull request with your own `gh`.",
1528
+ "",
1529
+ "Options:",
1530
+ " --merge-workflow Select this command.",
1531
+ " --codeowner <@owner> REQUIRED. The one @user or @org/team that should own",
1532
+ " review of the workflow path. Not inferred.",
1533
+ " --dry-run Print the plan and perform NO write: no branch, no",
1534
+ " file, no install-state write, no commit, no push,",
1535
+ " and no pull request.",
1536
+ "",
1537
+ "It never merges the pull request and never asks for a credential. The Bridge",
1538
+ `API key is named only (${MERGE_WORKFLOW_API_KEY_SECRET_NAME}); create that`,
1539
+ "repository Actions secret yourself after the pull request lands.",
1540
+ ].join("\n");
1541
+ }
1542
+ /**
1543
+ * Build the installer's seams from the final merged installer deps.
1544
+ *
1545
+ * `git` and `gh` run through {@link defaultShellFreeRunner} — argument arrays,
1546
+ * `shell: false` — and `gh` additionally receives the non-interactive pins so a
1547
+ * prompt can never hang an install. The resulting environment is never printed.
1548
+ */
1549
+ export function buildMergeWorkflowInstallDeps(deps, log, errorLog) {
1550
+ const toResult = (r) => ({
1551
+ status: r.exitCode,
1552
+ stdout: r.stdout,
1553
+ stderr: r.stderr,
1554
+ });
1555
+ return {
1556
+ cwd: deps.cwd,
1557
+ log,
1558
+ errorLog,
1559
+ readFile: deps.readFile,
1560
+ writeFile: deps.writeFile,
1561
+ mkdir: deps.mkdir,
1562
+ runGit: deps.mergeWorkflowRunGit ??
1563
+ (async (args) => toResult(await defaultShellFreeRunner("git", args))),
1564
+ runGh: deps.mergeWorkflowRunGh ??
1565
+ (async (args) => toResult(await defaultShellFreeRunnerWithEnv("gh", args, {
1566
+ GH_PROMPT_DISABLED: "1",
1567
+ GH_NO_UPDATE_NOTIFIER: "1",
1568
+ }))),
1569
+ confirmWrite: deps.isTTY && deps.promptLine
1570
+ ? async (promptText) => {
1571
+ const answer = (await deps.promptLine(promptText)).trim().toLowerCase();
1572
+ return answer === "y" || answer === "yes";
1573
+ }
1574
+ : undefined,
1575
+ recordArtifact: (cwd, relPath) => recordInstalledProjectArtifact(cwd, relPath, {
1576
+ readFile: deps.readFile,
1577
+ writeFile: (p, data) => deps.writeFile(p, data),
1578
+ rename: deps.rename,
1579
+ mkdir: (p, o) => deps.mkdir(p, o),
1580
+ unlink: deps.unlink,
1581
+ }),
1582
+ };
1583
+ }
1584
+ /**
1585
+ * {@link defaultShellFreeRunner} with extra environment entries overlaid.
1586
+ *
1587
+ * Kept separate rather than widening the shared runner's signature: every other
1588
+ * caller wants the inherited environment exactly as it is, and an optional
1589
+ * parameter that only one caller passes is an invitation to pass it wrongly.
1590
+ *
1591
+ * NOTE ON THE INHERITED ENVIRONMENT (BAPI-1136). This spread is deliberate and
1592
+ * is NOT the ambient-identity pattern this ticket removes from the merge path.
1593
+ * The two are opposites: a merge must authenticate as one explicitly configured
1594
+ * credential, because it performs a privileged write on Bridge's behalf; this
1595
+ * `gh` opens a pull request AS THE CUSTOMER, using their own login, which is
1596
+ * exactly what makes the resulting review theirs. `conductor/local-merge.ts`'s
1597
+ * allowlisted child environment is where the other rule lives.
1598
+ */
1599
+ function defaultShellFreeRunnerWithEnv(executable, args, extraEnv) {
1600
+ return new Promise((resolve, reject) => {
1601
+ const child = spawn(executable, args, {
1602
+ shell: false,
1603
+ stdio: ["ignore", "pipe", "pipe"],
1604
+ env: { ...process.env, ...extraEnv },
1605
+ });
1606
+ let stdout = "";
1607
+ let stderr = "";
1608
+ child.stdout?.on("data", (chunk) => {
1609
+ stdout += chunk.toString("utf-8");
1610
+ });
1611
+ child.stderr?.on("data", (chunk) => {
1612
+ stderr += chunk.toString("utf-8");
1613
+ });
1614
+ child.on("error", reject);
1615
+ child.on("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
1616
+ });
1617
+ }
1377
1618
  /** Presence probe: `<tool> --version` exiting 0 means the tool resolves on PATH. */
1378
1619
  async function defaultExecutablePresenceProbe(name) {
1379
1620
  try {
@@ -1393,7 +1634,18 @@ async function defaultExecutablePresenceProbe(name) {
1393
1634
  * stays in `install-bridge-conductor.ts`; moving it into this dispatch layer
1394
1635
  * would put policy in the place least able to test it.
1395
1636
  */
1396
- function buildConductorExecutorDeps(deps, baseUrl) {
1637
+ /**
1638
+ * Compose the executor-provisioning bundle from the FINAL merged deps.
1639
+ *
1640
+ * EXPORTED for testing (BAPI-1137). The bundle's seams are only reachable
1641
+ * through the nested conductor runner after two consents and a successful
1642
+ * start, so driving the whole CLI to assert "the durability probe got the
1643
+ * injected uid" turns a one-line property into an end-to-end rehearsal. Every
1644
+ * entry here is a thin adapter over an implementation that is tested elsewhere;
1645
+ * what this function decides is only WHICH seam each one is wired to, and that
1646
+ * is exactly what a direct test can check.
1647
+ */
1648
+ export function buildConductorExecutorDeps(deps, baseUrl) {
1397
1649
  const homeDir = deps.homedir();
1398
1650
  const lookupExecutable = deps.lookupExecutable ?? defaultExecutablePresenceProbe;
1399
1651
  const runLifecycleCommand = deps.runLifecycleCommand ?? defaultShellFreeRunner;
@@ -1416,7 +1668,14 @@ function buildConductorExecutorDeps(deps, baseUrl) {
1416
1668
  writeUnit: (plan) => writeExecutorServicePlan(plan, {
1417
1669
  stat: (filePath) => deps.stat(filePath),
1418
1670
  mkdir: (dirPath, options) => deps.mkdir(dirPath, options),
1419
- writeFile: (filePath, content) => deps.writeFile(filePath, content),
1671
+ // BAPI-1137: the write mode is passed through EXPLICITLY rather than
1672
+ // left to the process umask — a unit names the repositories and endpoint
1673
+ // this host serves, and how readable it ends up must not depend on the
1674
+ // shell that happened to run the installer.
1675
+ writeFile: (filePath, content, options) => deps.writeFile(filePath, content, options ?? { encoding: "utf-8", mode: EXECUTOR_SERVICE_UNIT_FILE_MODE }),
1676
+ // The permission seam, so an ALREADY-PRESENT unit's mode is corrected
1677
+ // too: `writeFile`'s mode only applies at creation.
1678
+ chmod: deps.chmod,
1420
1679
  // Supplying `readFile` is what enables the `unchanged` outcome, so a
1421
1680
  // re-run against an identical unit reports the truth instead of an
1422
1681
  // unnecessary rewrite.
@@ -1433,6 +1692,29 @@ function buildConductorExecutorDeps(deps, baseUrl) {
1433
1692
  return result.ok ? { ok: true } : { ok: false, error: result.error };
1434
1693
  },
1435
1694
  startService: (plan) => startExecutorService(plan, runLifecycleCommand),
1695
+ // BAPI-1137 — both new seams are THIN adapters over `service-lifecycle.ts`,
1696
+ // exactly like `startService` above. No probe is built here, no command
1697
+ // string is assembled here, and no policy about what a state means lives
1698
+ // here: this file stays a dispatch layer, and the module that owns
1699
+ // launchctl/systemctl/loginctl keeps owning them.
1700
+ inspectServiceState: (plan) => inspectExecutorServiceState({
1701
+ platform: plan.platform,
1702
+ unitPath: plan.unitPath,
1703
+ serviceIdentifier: plan.serviceIdentifier,
1704
+ // The same injected `getuid` seam the start plan uses — the launchd
1705
+ // domain target needs it, and reading it here rather than inside the
1706
+ // lifecycle module keeps that module free of ambient process state.
1707
+ uid: deps.getuid ? deps.getuid() : null,
1708
+ }, runLifecycleCommand),
1709
+ inspectDurability: (plan) => inspectExecutorServiceDurability({
1710
+ platform: plan.platform,
1711
+ unitPath: plan.unitPath,
1712
+ serviceIdentifier: plan.serviceIdentifier,
1713
+ // Linux lingering is a property of the USER (`loginctl show-user
1714
+ // <uid>`) and the macOS login domain is `gui/<uid>`, so both probes
1715
+ // need the numeric uid from this same seam.
1716
+ uid: deps.getuid ? deps.getuid() : null,
1717
+ }, runLifecycleCommand),
1436
1718
  now: deps.monotonicNow ?? (() => Number(process.hrtime.bigint() / 1000000n)),
1437
1719
  sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1438
1720
  };
@@ -4030,7 +4312,7 @@ export function buildDryRunPreview(ctx) {
4030
4312
  ...credentialStep,
4031
4313
  ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
4032
4314
  ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
4033
- ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? []) : []),
4315
+ ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? [], ctx.host.notesPlatformLabels ?? []) : []),
4034
4316
  ];
4035
4317
  }
4036
4318
  /**
@@ -4142,14 +4424,21 @@ function buildDeferredLaunchStepPreview() {
4142
4424
  * no writable target (e.g. Windsurf-only), mirroring how the GitHub offer
4143
4425
  * lines are unconditional but this offer's applicability is not.
4144
4426
  */
4145
- function buildAgentNotesPreviewLines(notesTargets) {
4427
+ function buildAgentNotesPreviewLines(notesTargets, platformLabels) {
4146
4428
  if (notesTargets.length === 0)
4147
4429
  return [];
4430
+ const { subject, plural } = describeAgentNotesPlatforms(platformLabels);
4148
4431
  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",
4432
+ // BAPI-1247: state the sequencing explicitly. A real run reaches this point only
4433
+ // after Step 4b has ended one way or the other, and the live run now says so out
4434
+ // loud, so the preview says so too rather than leaving the order implied.
4435
+ "Step 4c — reached only after the Step 4b VCS connect offer has completed or been",
4436
+ " explicitly deferred (a declined or incomplete connect is announced first,",
4437
+ " with the command to finish it later, and never blocks this step).",
4438
+ `Step 4c — optional: Bridge MCP guidance for ${subject} (SKIPPED in --dry-run): if the`,
4439
+ ` terminal is interactive, offer to write guidance so ${subject} know${plural ? "" : "s"} when to reach`,
4440
+ ` for Bridge's MCP tools, in a Bridge-owned marker region in ${notesTargets.join(" and ")}; defaults`,
4441
+ " to No, and existing content outside that region is always preserved. No notes files are",
4153
4442
  " written in --dry-run.",
4154
4443
  ];
4155
4444
  }
@@ -4233,7 +4522,7 @@ function buildBootstrapDryRunPreview(ctx) {
4233
4522
  ...promoteStep,
4234
4523
  ...buildCommitAssetsPreviewLines(ctx.commitSafeAssets, ctx.host.kind === "explicit" ? ctx.host.configTargets : []),
4235
4524
  ...(ctx.host.kind === "explicit" ? buildLaunchStepPreview(ctx.host.launch) : buildDeferredLaunchStepPreview()),
4236
- ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? []) : []),
4525
+ ...(ctx.host.kind === "explicit" ? buildAgentNotesPreviewLines(ctx.host.notesTargets ?? [], ctx.host.notesPlatformLabels ?? []) : []),
4237
4526
  ];
4238
4527
  }
4239
4528
  /**
@@ -4659,6 +4948,40 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4659
4948
  // `epic-implementer` name. This nested `install-bridge conductor` surface is
4660
4949
  // untouched by that rename.
4661
4950
  if (argv[0] === "conductor") {
4951
+ // ---- BAPI-1136: `install-bridge conductor --merge-workflow` ----------
4952
+ //
4953
+ // Intercepted ahead of `parseInstallBridgeConductorArgs` on purpose. That
4954
+ // parser REQUIRES at least one `--required-check` because an empty done gate
4955
+ // passes unconditionally — a rule that is right for the bootstrap phase and
4956
+ // meaningless for this one, which writes three files and opens a pull
4957
+ // request and has no done gate at all. Routing here keeps the bootstrap
4958
+ // parser's invariant intact instead of weakening it for a sibling command.
4959
+ //
4960
+ // It also runs BEFORE any MCP server construction or connection, and before
4961
+ // the base-URL resolution below, because this command makes no Bridge API
4962
+ // call whatsoever: it talks only to the customer's own git and `gh`.
4963
+ const mergeWorkflowArgs = parseMergeWorkflowInstallArgs(argv.slice(1));
4964
+ if (mergeWorkflowArgs.status === "selected") {
4965
+ if (mergeWorkflowArgs.error) {
4966
+ errorLog(`Error: ${mergeWorkflowArgs.error}`);
4967
+ errorLog("");
4968
+ errorLog(getMergeWorkflowInstallUsage());
4969
+ return 1;
4970
+ }
4971
+ try {
4972
+ return await runMergeWorkflowInstall(deps.cwd, mergeWorkflowArgs.options, buildMergeWorkflowInstallDeps(deps, log, errorLog));
4973
+ }
4974
+ catch {
4975
+ // Containment. A domain or subprocess exception escaping here would
4976
+ // print an unbounded message that can carry a remote URL with an
4977
+ // embedded token; the named reason plus a remediation sentence is the
4978
+ // only operator-facing surface this command has.
4979
+ errorLog("merge_workflow_install_failed");
4980
+ errorLog("The merge-workflow installer stopped unexpectedly. Re-run it; anything already " +
4981
+ "created was left in place.");
4982
+ return 1;
4983
+ }
4984
+ }
4662
4985
  if (!baseUrlResult.ok) {
4663
4986
  errorLog(baseUrlResult.error);
4664
4987
  return 1;
@@ -4783,6 +5106,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4783
5106
  let host;
4784
5107
  if (dryRunPlatforms !== undefined) {
4785
5108
  const dryRunTargets = hostConfigTargetsForPlatforms(dryRunPlatforms);
5109
+ const dryRunNotesPlan = resolveAgentNotesTargets(dryRunPlatforms);
4786
5110
  const launchDecision = resolveInstallBridgeLaunchDecision(dryRunPlatforms, options.agentName);
4787
5111
  let launch;
4788
5112
  if (launchDecision.kind === "spawn") {
@@ -4809,7 +5133,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4809
5133
  configTargets: dryRunTargets.map((t) => t.relPath),
4810
5134
  manualEditors: manualEditorNames(await detectManualEditors(deps)),
4811
5135
  launch,
4812
- notesTargets: resolveAgentNotesTargets(dryRunPlatforms).targets,
5136
+ notesTargets: dryRunNotesPlan.targets,
5137
+ notesPlatformLabels: labelsForPlatforms(dryRunNotesPlan.supportedPlatforms),
4813
5138
  };
4814
5139
  }
4815
5140
  else {
@@ -6088,7 +6413,8 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
6088
6413
  // selected set. `writeAgentNotes` never touches `writeMcpInstallState` or any
6089
6414
  // other persisted install-state contract — consent is re-evaluated every run.
6090
6415
  const notesPlan = resolveAgentNotesTargets(selectedPlatforms);
6091
- const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets);
6416
+ const notesPlatformLabels = labelsForPlatforms(notesPlan.supportedPlatforms);
6417
+ const notesOffer = await offerAgentGuidanceNotes(deps, log, notesPlan.targets, notesPlatformLabels);
6092
6418
  let notesResult;
6093
6419
  let notesWriteFailed = false;
6094
6420
  if (notesOffer.kind === "consented") {
@@ -64,12 +64,46 @@ function normalizePlatforms(ids) {
64
64
  const wanted = new Set(ids);
65
65
  return HOST_PLATFORM_ORDER.filter((id) => wanted.has(id));
66
66
  }
67
- /** Dedupe + stably order relative project config paths. */
67
+ /**
68
+ * Is this a plain, repository-relative POSIX path safe to record?
69
+ *
70
+ * BAPI-1136. The install manifest is read back by `doctor` and by the installer
71
+ * itself to decide which files Bridge manages. A recorded absolute path, a `..`
72
+ * escape, or a Windows separator therefore names a file OUTSIDE the project that
73
+ * a later tool would treat as project-owned. The install-state file's own path is
74
+ * excluded for the same reason — recording it would make the manifest claim
75
+ * itself as a managed artifact.
76
+ */
77
+ export function isRecordableProjectPath(relPath) {
78
+ if (typeof relPath !== "string" || relPath.length === 0)
79
+ return false;
80
+ if (relPath.includes("\\"))
81
+ return false;
82
+ if (relPath.startsWith("/"))
83
+ return false;
84
+ if (/^[A-Za-z]:/.test(relPath))
85
+ return false;
86
+ if (relPath === MCP_INSTALL_STATE_RELPATH)
87
+ return false;
88
+ const segments = relPath.split("/");
89
+ if (segments.some((seg) => seg.length === 0 || seg === "." || seg === ".."))
90
+ return false;
91
+ return true;
92
+ }
93
+ /**
94
+ * Dedupe + stably order relative project config paths, DROPPING any path that is
95
+ * not recordable.
96
+ *
97
+ * BAPI-1136 widened this from a non-empty-string filter. The filter runs on the
98
+ * read side too, so a malformed path written by an older build (or by hand) is
99
+ * dropped rather than faithfully reserialized — a normalizer that preserves
100
+ * every value it was handed turns one bad write into a permanent one.
101
+ */
68
102
  function normalizeProjectPaths(paths) {
69
103
  const seen = new Set();
70
104
  const out = [];
71
105
  for (const p of paths) {
72
- if (typeof p === "string" && p.length > 0 && !seen.has(p)) {
106
+ if (isRecordableProjectPath(p) && !seen.has(p)) {
73
107
  seen.add(p);
74
108
  out.push(p);
75
109
  }
@@ -337,8 +371,12 @@ async function persistMcpInstallState(cwd, state, finalPath, deps) {
337
371
  * has no field for one, and the serializer emits only schema-approved keys.
338
372
  */
339
373
  export async function recordInstalledProjectArtifact(cwd, relPath, deps) {
340
- if (typeof relPath !== "string" || relPath.length === 0) {
341
- return { ok: false, error: "artifact path must be a non-empty relative path" };
374
+ if (!isRecordableProjectPath(relPath)) {
375
+ return {
376
+ ok: false,
377
+ error: "artifact path must be a repository-relative POSIX path with no absolute prefix, " +
378
+ "traversal segment, empty segment, or backslash, and must not be the install-state file itself",
379
+ };
342
380
  }
343
381
  const existing = await readMcpInstallState(cwd, deps);
344
382
  const priorPlatforms = existing.status === "valid" ? existing.state.selectedPlatforms : [];